From 0e2924a5edcc62e49443fa46e8e73660d284717f Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:30:58 +0200 Subject: [PATCH 1/3] feat(cli): Route commands through selected project roots Propagate explicit project and setup roots across CLI commands, validate the selected project even with an explicit setup root, and document the behavior. Signed-off-by: GPT-5.6 Sol Co-Authored-By: GPT-5.6 Sol --- README.md | 34 ++- cmd/config.go | 7 +- cmd/context.go | 11 +- cmd/context_test.go | 16 ++ cmd/doctor.go | 20 +- cmd/doctor_scope_test.go | 2 +- cmd/doctor_test.go | 59 +++++ cmd/hooks.go | 30 ++- cmd/hooks_more_test.go | 131 ++++++++++++ cmd/integration_command.go | 13 +- cmd/plugin.go | 14 +- cmd/root.go | 32 ++- cmd/root_test.go | 60 ++++++ cmd/serve.go | 8 +- cmd/setup.go | 44 ++-- cmd/setup_review_test.go | 2 +- cmd/setup_run_test.go | 36 ++++ cmd/setup_test.go | 37 ++++ cmd/skill.go | 13 +- cmd/skill_test.go | 19 ++ docs/HOOKS.md | 33 ++- docs/MCP.md | 37 ++++ internal/projectpath/path.go | 341 +++++++++++++++++++++++++++++ internal/projectpath/path_test.go | 345 ++++++++++++++++++++++++++++++ main.go | 96 +++++++-- main_more_test.go | 187 +++++++++++++++- main_test.go | 2 + 27 files changed, 1562 insertions(+), 67 deletions(-) create mode 100644 cmd/doctor_test.go create mode 100644 internal/projectpath/path.go create mode 100644 internal/projectpath/path_test.go diff --git a/README.md b/README.md index 575c495..9dff389 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,10 @@ curl -fsSL "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/JordanCoin/codemap/releases/download/v${CODEMAP_V ## Setup -Run from your git repo root — hooks resolve project context from the working directory. +Run setup anywhere inside your git repo. Repo-scoped commands such as +`setup`, `doctor`, `config`, `watch`, `skill`, `context`, `serve`, and +managed hooks resolve the nearest git root automatically, including linked +worktrees with a `.git` file. ```bash cd /path/to/your/project @@ -163,8 +166,37 @@ codemap --version ### Options +Standard linked Git worktrees automatically reuse the primary worktree's +`.codemap/config.json` and project skills. Create the worktree with Git, an IDE, +or any manager that uses standard linked-worktree metadata, then give the agent +its absolute path: + +```bash +git worktree add -b +codemap -C /tmp/feature-worktree context +``` + +Normal CLI and plugin MCP calls need no `--setup-root`: central config and skills +come from the primary worktree, while handoffs, watcher files, and hook/session +state remain in the linked worktree. Independent clones have no trusted Git +metadata linking them, so sharing setup between them still requires an explicit +override: + +```bash +codemap -C /tmp/independent-clone --setup-root /path/to/original context +``` + +`-C`/`--project-root` selects the repository Codemap operates on. +`--setup-root` explicitly reuses `/.codemap` policy and runtime state +from another checkout. Both accept a repository or subdirectory; relative setup +paths resolve from the project root. + +| Flag | Description | + | Flag | Description | |------|-------------| +| `-C, --project-root ` | Operate on code in `` | +| `--setup-root ` | Explicitly reuse policy and runtime state from `/.codemap` | | `--depth, -d ` | Limit tree depth (0 = unlimited) | | `--only ` | Only include files with these extensions | | `--exclude ` | Exclude files matching patterns | diff --git a/cmd/config.go b/cmd/config.go index e89d7e8..b286b7a 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -41,7 +41,12 @@ var nonCodeExtensions = map[string]bool{ // RunConfig dispatches the "config" subcommand. func RunConfig(subCmd, root string) { - absRoot, err := filepath.Abs(root) + absRoot, _, err := ResolveNearestGitRoot(root) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + absRoot, err = ValidateProjectPath(absRoot) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) diff --git a/cmd/context.go b/cmd/context.go index 28267e4..671e67a 100644 --- a/cmd/context.go +++ b/cmd/context.go @@ -89,11 +89,20 @@ func RunContext(args []string, root string) { os.Exit(1) } - if fs.NArg() > 0 { + explicitRoot := fs.NArg() > 0 + if explicitRoot { root = fs.Arg(0) } absRoot, err := filepath.Abs(root) + if !explicitRoot { + absRoot, _, err = ResolveNearestGitRoot(root) + } + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + absRoot, err = ValidateProjectPath(absRoot) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) diff --git a/cmd/context_test.go b/cmd/context_test.go index f356724..abb1387 100644 --- a/cmd/context_test.go +++ b/cmd/context_test.go @@ -11,6 +11,22 @@ import ( "codemap/watch" ) +func TestRunContextPreservesExplicitSubtree(t *testing.T) { + root := t.TempDir() + nested := filepath.Join(root, "pkg") + mustWriteFile(t, filepath.Join(root, ".git", "HEAD"), "ref: refs/heads/main\n") + mustWriteFile(t, filepath.Join(nested, "main.go"), "package pkg\n") + + var envelope ContextEnvelope + out := captureOutput(func() { RunContext([]string{nested}, root) }) + if err := json.Unmarshal([]byte(out), &envelope); err != nil { + t.Fatal(err) + } + if envelope.Project.Root != nested { + t.Fatalf("context root = %q, want explicit subtree %q", envelope.Project.Root, nested) + } +} + func TestDetectLanguagesFromFiles_ManifestSignals(t *testing.T) { root := t.TempDir() diff --git a/cmd/doctor.go b/cmd/doctor.go index 145da3f..e407a66 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -14,6 +14,7 @@ import ( "strings" "time" + "codemap/config" "codemap/internal/buildinfo" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -71,7 +72,12 @@ func RunDoctor(args []string, defaultRoot string) int { if fs.NArg() == 1 { root = fs.Arg(0) } - root, err := filepath.Abs(root) + root, _, err := ResolveNearestGitRoot(root) + if err != nil { + fmt.Fprintf(os.Stderr, "Error resolving path: %v\n", err) + return 1 + } + root, err = ValidateProjectPath(root) if err != nil { fmt.Fprintf(os.Stderr, "Error resolving path: %v\n", err) return 1 @@ -100,7 +106,7 @@ func RunDoctor(args []string, defaultRoot string) int { fmt.Printf("OK %s: %s\n", label, path) } - checkFile("project config", filepath.Join(root, ".codemap", "config.json"), validateJSONFile) + checkFile("project config", config.ConfigPath(root), validateJSONFile) claudeSettings, claudeSettingsErr := claudeSettingsPath(root, *global) claudeMCP, claudeMCPErr := claudeMCPPath(root, *global) codexHooks, codexHooksErr := codexHooksPath(root, *global) @@ -605,14 +611,18 @@ func parseDoctorManagedLaunch(raw any, agent string) (doctorManagedLaunch, error return doctorManagedLaunch{}, fmt.Errorf("codemap MCP command must be a non-empty string") } args := mcpServerArgs(server) - launch := doctorManagedLaunch{command: command, args: args, integration: doctorManagedIntegration(args)} + _, managedArgs, err := ParseGlobalRootOptions(args) + if err != nil { + return doctorManagedLaunch{}, fmt.Errorf("invalid codemap root arguments: %w", err) + } + launch := doctorManagedLaunch{command: command, args: args, integration: doctorManagedIntegration(managedArgs)} if command == "codemap" && stringSlicesEqual(args, []string{"mcp"}) { return launch, fmt.Errorf("legacy PATH-relative codemap MCP definition is stale") } - if len(args) != 5 || args[0] != "mcp" || args[1] != "--configured-version" || args[2] == "" || args[3] != "--integration" { + if len(managedArgs) != 5 || managedArgs[0] != "mcp" || managedArgs[1] != "--configured-version" || managedArgs[2] == "" || managedArgs[3] != "--integration" { return launch, fmt.Errorf("unrecognized codemap MCP arguments") } - launch.configuredVersion = args[2] + launch.configuredVersion = managedArgs[2] if !filepath.IsAbs(command) { return launch, fmt.Errorf("codemap MCP command is not absolute: %q", command) } diff --git a/cmd/doctor_scope_test.go b/cmd/doctor_scope_test.go index 59f98e0..3878470 100644 --- a/cmd/doctor_scope_test.go +++ b/cmd/doctor_scope_test.go @@ -230,7 +230,7 @@ func TestRunDoctorFindsLocalScopedClaudeMCP(t *testing.T) { } server := map[string]any{ "command": binary, - "args": managedMCPArgs("1.0.0", "claude-setup"), + "args": managedMCPArgs(root, "1.0.0", "claude-setup"), } payload := map[string]any{ "mcpServers": map[string]any{}, diff --git a/cmd/doctor_test.go b/cmd/doctor_test.go new file mode 100644 index 0000000..9a3f10a --- /dev/null +++ b/cmd/doctor_test.go @@ -0,0 +1,59 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRunDoctorUsesNearestGitRootFromNestedDirectory(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, ".git"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "main.go"), []byte("package main\n"), 0o644); err != nil { + t.Fatal(err) + } + + setupCode := RunSetup([]string{"--agent", "claude"}, root) + if setupCode != 0 { + t.Fatalf("RunSetup() exit code = %d, want 0", setupCode) + } + + prevLookPath := doctorLookPath + prevVersionProbe := doctorVersionProbe + prevMCPProbe := doctorMCPProbe + doctorLookPath = func(name string) (string, error) { + return filepath.Join("/tmp", name), nil + } + doctorVersionProbe = func(launch doctorManagedLaunch) error { return nil } + doctorMCPProbe = func(launch doctorManagedLaunch) error { return nil } + t.Cleanup(func() { + doctorLookPath = prevLookPath + doctorVersionProbe = prevVersionProbe + doctorMCPProbe = prevMCPProbe + }) + + nested := filepath.Join(root, "pkg", "feature") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatal(err) + } + + var code int + out := captureOutput(func() { + code = RunDoctor([]string{"--agent", "claude"}, nested) + }) + if code != 0 { + t.Fatalf("RunDoctor() exit code = %d, want 0 with output:\n%s", code, out) + } + + rootConfig := filepath.Join(root, ".codemap", "config.json") + nestedConfig := filepath.Join(nested, ".codemap", "config.json") + if !strings.Contains(out, rootConfig) { + t.Fatalf("expected doctor output to validate root config %q, got:\n%s", rootConfig, out) + } + if strings.Contains(out, nestedConfig) { + t.Fatalf("expected doctor output to avoid nested config path %q, got:\n%s", nestedConfig, out) + } +} diff --git a/cmd/hooks.go b/cmd/hooks.go index a7c7f4f..5d6fe4f 100644 --- a/cmd/hooks.go +++ b/cmd/hooks.go @@ -19,6 +19,7 @@ import ( "codemap/config" "codemap/handoff" + "codemap/internal/projectpath" "codemap/limits" "codemap/scanner" "codemap/skills" @@ -235,6 +236,15 @@ func waitForDaemonState(root string, timeout time.Duration) *watch.State { // RunHook executes the named hook with the given project root func RunHook(hookName, root string) error { + resolvedRoot, _, err := ResolveNearestGitRoot(root) + if err != nil { + return err + } + root, err = ValidateProjectPath(resolvedRoot) + if err != nil { + return err + } + var fn func() error switch hookName { case "session-start": @@ -348,7 +358,7 @@ func hookSessionStart(root string) error { depth = projCfg.Depth } - args := []string{"--depth", strconv.Itoa(depth)} + args := projectpath.PrependSetupRootArgs("--depth", strconv.Itoa(depth)) if len(projCfg.Only) > 0 { args = append(args, "--only", strings.Join(projCfg.Only, ",")) } @@ -451,7 +461,7 @@ func showDiffVsMain(root string, fileCount int, fileCountKnown bool, projCfg con // Run codemap --diff to show richer impact analysis on manageable repos. diffBudget := projCfg.DiffOutputBytes() - args := []string{"--diff"} + args := projectpath.PrependSetupRootArgs("--diff") if len(projCfg.Only) > 0 { args = append(args, "--only", strings.Join(projCfg.Only, ",")) } @@ -529,7 +539,7 @@ func showLightweightDiffVsMain(root string) { // getLastSessionEvents reads events.log for previous session context func getLastSessionEvents(root string) []string { - eventsFile := filepath.Join(root, ".codemap", "events.log") + eventsFile := filepath.Join(projectpath.RuntimeCodemapDir(root), "events.log") f, err := os.Open(eventsFile) if err != nil { return nil @@ -696,7 +706,8 @@ func startDaemon(root string) { if err != nil { return } - cmd := hookExecCommand(exe, "watch", "start", root) + args := projectpath.PrependSetupRootArgs("watch", "start", root) + cmd := hookExecCommand(exe, args...) cmd.Stdout = nil cmd.Stderr = nil cmd.Stdin = nil @@ -876,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 := filepath.Join(root, ".codemap") + codemapDir := projectpath.RuntimeCodemapDir(root) status := intent.Category if intent.RiskLevel != "low" { status += " " + intent.RiskLevel @@ -1368,7 +1379,7 @@ func showSessionProgress(root, sessionID string) { // hookPreCompact saves hub state before context compaction func hookPreCompact(root string) error { - codemapDir := filepath.Join(root, ".codemap") + codemapDir := projectpath.RuntimeCodemapDir(root) if err := os.MkdirAll(codemapDir, 0755); err != nil { return err } @@ -1683,7 +1694,7 @@ func updateSessionLease(root, sessionID string, active bool, now time.Time, acti } return nil } - codemapDir := filepath.Join(root, ".codemap") + codemapDir := projectpath.RuntimeCodemapDir(root) if err := os.MkdirAll(codemapDir, 0o755); err != nil { return err } @@ -1775,7 +1786,8 @@ func stopDaemon(root string) { if err != nil { return } - cmd := hookExecCommand(exe, "watch", "stop", root) + args := projectpath.PrependSetupRootArgs("watch", "stop", root) + cmd := hookExecCommand(exe, args...) cmd.Run() } @@ -2065,7 +2077,7 @@ func hookSessionStartMultiRepo(root string, childRepos []string) error { depth = projCfg.Depth } - args := []string{"--depth", strconv.Itoa(depth)} + args := projectpath.PrependSetupRootArgs("--depth", strconv.Itoa(depth)) if len(projCfg.Only) > 0 { args = append(args, "--only", strings.Join(projCfg.Only, ",")) } diff --git a/cmd/hooks_more_test.go b/cmd/hooks_more_test.go index 01b9f3e..ba680e5 100644 --- a/cmd/hooks_more_test.go +++ b/cmd/hooks_more_test.go @@ -14,6 +14,7 @@ import ( "codemap/config" "codemap/handoff" + "codemap/internal/projectpath" "codemap/limits" "codemap/watch" ) @@ -651,6 +652,32 @@ func TestFindChildReposAndSessionStartVariants(t *testing.T) { }) } +func TestRunHookUsesNearestGitRootFromNestedDirectory(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, ".git"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "main.go"), []byte("package main\n"), 0o644); err != nil { + t.Fatal(err) + } + nested := filepath.Join(root, "pkg", "feature") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatal(err) + } + + var hookErr error + out := captureOutput(func() { hookErr = RunHook("session-start", nested) }) + if hookErr != nil { + t.Fatalf("RunHook() error: %v", hookErr) + } + if !strings.Contains(out, "Project Context") { + t.Fatalf("expected hook output to include project context, got:\n%s", out) + } + if strings.Contains(out, "Not a git repository") { + t.Fatalf("expected hook output to avoid nested non-git fallback, got:\n%s", out) + } +} + func TestHookSessionStopSummaryBranches(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("git not available") @@ -752,6 +779,33 @@ func TestDaemonCommandHelpersAndMultiRepoShellout(t *testing.T) { } }) + t.Run("daemon shellouts preserve configured setup root", func(t *testing.T) { + projectpath.SetSetupRoot("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/setup/repo") + t.Cleanup(projectpath.ResetSetupRoot) + var calls [][]string + withHookRuntimeStubs( + t, + func() (string, error) { return "/tmp/codemap-hook", nil }, + func(_ string, args ...string) *exec.Cmd { + calls = append(calls, append([]string(nil), args...)) + return exec.Command("sh", "-c", "exit 0") + }, + func(string) bool { return true }, + func(time.Duration) {}, + ) + + startDaemon("/project") + stopDaemon("/project") + want := []string{"--setup-root", "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/setup/repo", "watch", "start", "/project"} + if len(calls) != 2 || strings.Join(calls[0], "|") != strings.Join(want, "|") { + t.Fatalf("start daemon args = %v, want %v", calls, want) + } + want[3] = "stop" + if strings.Join(calls[1], "|") != strings.Join(want, "|") { + t.Fatalf("stop daemon args = %v, want %v", calls[1], want) + } + }) + t.Run("multi repo start shells out for each child repo", func(t *testing.T) { root := t.TempDir() for _, repo := range []string{"svc-a", "svc-b"} { @@ -830,3 +884,80 @@ func TestDaemonCommandHelpersAndMultiRepoShellout(t *testing.T) { } }) } + +func TestHookFilesUseSetupRoot(t *testing.T) { + projectRoot := t.TempDir() + setupRoot := t.TempDir() + projectpath.SetSetupRoot(setupRoot) + t.Cleanup(projectpath.ResetSetupRoot) + codemapDir := filepath.Join(setupRoot, ".codemap") + if err := os.MkdirAll(codemapDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(codemapDir, "events.log"), []byte("setup event\n"), 0o644); err != nil { + t.Fatal(err) + } + + events := getLastSessionEvents(projectRoot) + if len(events) != 1 || events[0] != "setup event" { + t.Fatalf("getLastSessionEvents() = %#v, want setup event", events) + } + + writeStatuslineState(projectRoot, TaskIntent{Category: "feature", RiskLevel: "low"}) + data, err := os.ReadFile(filepath.Join(codemapDir, "status")) + if err != nil { + t.Fatalf("read setup-root status: %v", err) + } + if string(data) != "feature" { + t.Fatalf("status = %q, want feature", data) + } +} + +func TestAutomaticLinkedWorktreeUsesLocalHookState(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(primary, ".codemap", "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 { + 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 { + t.Fatal(err) + } + linked, _ = filepath.EvalSymlinks(linked) + + events := getLastSessionEvents(linked) + if len(events) != 1 || events[0] != "linked event" { + 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 { + t.Fatalf("linked status missing: %v", err) + } + if _, err := os.Stat(filepath.Join(primary, ".codemap", "status")); !os.IsNotExist(err) { + t.Fatalf("primary status unexpectedly created: %v", err) + } + 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")) + if err != nil || len(entries) != 1 { + t.Fatalf("linked session lease entries = %d, err = %v", len(entries), err) + } +} diff --git a/cmd/integration_command.go b/cmd/integration_command.go index a675ff1..ddc45ed 100644 --- a/cmd/integration_command.go +++ b/cmd/integration_command.go @@ -7,6 +7,8 @@ import ( "path/filepath" "runtime" "strings" + + "codemap/internal/projectpath" ) var ( @@ -80,6 +82,13 @@ func quoteHookExecutable(path, goos string) string { return `'` + strings.ReplaceAll(path, `'`, `'"'"'`) + `'` } -func managedMCPArgs(version, integration string) []string { - return []string{"mcp", "--configured-version", version, "--integration", integration} +func managedMCPArgs(projectRoot, version, integration string) []string { + args := make([]string, 0, 9) + if projectRoot != "" { + args = append(args, "--project-root", projectRoot) + } + if setupRoot := projectpath.ConfiguredSetupRoot(); setupRoot != "" { + args = append(args, "--setup-root", setupRoot) + } + return append(args, "mcp", "--configured-version", version, "--integration", integration) } diff --git a/cmd/plugin.go b/cmd/plugin.go index 4fe8c19..19f6738 100644 --- a/cmd/plugin.go +++ b/cmd/plugin.go @@ -281,7 +281,7 @@ func migrateExistingCodexProjectIntegration(executable string) (int, error) { if owned, err := hasOwnedCodexMCP(configPath); err != nil { return migrated, err } else if owned { - changed, err := ensureCodexMCPWithExecutable(configPath, executable) + changed, err := ensureCodexMCPWithExecutable(configPath, executable, root) if err != nil { return migrated, err } @@ -305,15 +305,11 @@ func migrateExistingCodexProjectIntegration(executable string) (int, error) { } func existingGitRepositoryRoot(start string) string { - for current := start; ; current = filepath.Dir(current) { - if _, err := os.Stat(filepath.Join(current, ".git")); err == nil { - return current - } - parent := filepath.Dir(current) - if parent == current { - return "" - } + root, found, err := ResolveNearestGitRoot(start) + if err != nil || !found { + return "" } + return root } func hasOwnedCodexMCP(path string) (bool, error) { diff --git a/cmd/root.go b/cmd/root.go index d6c4dc4..76227fe 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -5,6 +5,8 @@ import ( "os" "path/filepath" "strings" + + "codemap/internal/projectpath" ) // GlobalRootOptions are invocation-wide roots extracted before command parsing. @@ -23,6 +25,8 @@ func (o GlobalRootOptions) Active() bool { type InvocationRoots struct { Project string Setup string + Runtime string + Source projectpath.Source } // ParseGlobalRootOptions extracts root options wherever they appear before --. @@ -90,8 +94,14 @@ func ResolveGlobalRoots(opts GlobalRootOptions, launchDir string) (InvocationRoo if opts.Directory != "" && !projectFound { return InvocationRoots{}, fmt.Errorf("resolve project root: %q is not inside a Git repository", projectInput) } + projectSelection, err := projectpath.Select(projectRoot) + if err != nil { + return InvocationRoots{}, fmt.Errorf("resolve project root: %w", err) + } setupRoot := projectRoot + runtimeRoot := projectRoot + source := projectpath.SourceProject if opts.SetupRoot != "" { setupInput := opts.SetupRoot if !filepath.IsAbs(setupInput) { @@ -105,12 +115,18 @@ func ResolveGlobalRoots(opts GlobalRootOptions, launchDir string) (InvocationRoo if !setupFound { return InvocationRoots{}, fmt.Errorf("resolve setup root: %q is not inside a Git repository", setupInput) } + runtimeRoot = setupRoot + source = projectpath.SourceExplicit + } else { + setupRoot = projectSelection.SetupRoot + runtimeRoot = projectSelection.RuntimeRoot + source = projectSelection.Source } if err := validateCodemapStorageRoot(setupRoot); err != nil { return InvocationRoots{}, fmt.Errorf("resolve setup root: %w", err) } - return InvocationRoots{Project: projectRoot, Setup: setupRoot}, nil + return InvocationRoots{Project: projectRoot, Setup: setupRoot, Runtime: runtimeRoot, Source: source}, nil } func validateCodemapStorageRoot(root string) error { @@ -128,6 +144,20 @@ func validateCodemapStorageRoot(root string) error { return nil } +// ValidateProjectPath returns the caller's absolute path only after automatic +// project selection succeeds. Keeping the exact path preserves commands that +// intentionally analyze a repository subdirectory. +func ValidateProjectPath(path string) (string, error) { + absPath, err := filepath.Abs(path) + if err != nil { + return "", err + } + if _, err := projectpath.Select(absPath); err != nil { + return "", err + } + return absPath, nil +} + // ResolveNearestGitRoot returns the nearest ancestor directory that contains a // .git entry. It accepts missing descendants and both .git directories and // .git files used by linked worktrees. When no repository root exists, it diff --git a/cmd/root_test.go b/cmd/root_test.go index 741e6c5..ad53b71 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -1,6 +1,7 @@ package cmd import ( + "codemap/internal/projectpath" "os" "path/filepath" "reflect" @@ -521,6 +522,38 @@ func TestResolveGlobalRoots(t *testing.T) { } }) + t.Run("directory alone discovers linked worktree setup", func(t *testing.T) { + primary := filepath.Join(launchDir, "primary") + 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(launchDir, "linked") + nested := filepath.Join(linked, "pkg") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(linked, ".git"), []byte("gitdir: "+gitDir+"\n"), 0o644); err != nil { + t.Fatal(err) + } + + roots, err := ResolveGlobalRoots(GlobalRootOptions{Directory: nested}, launchDir) + if err != nil { + t.Fatalf("ResolveGlobalRoots() error: %v", err) + } + primary = canonicalTestPath(t, primary) + linked = canonicalTestPath(t, linked) + if roots.Project != linked || roots.Setup != primary || roots.Runtime != linked || roots.Source != projectpath.SourceLinkedWorktree { + t.Fatalf("roots = %#v, want project/runtime %q, setup %q, linked source", roots, linked, primary) + } + }) + t.Run("relative setup root resolves after directory", func(t *testing.T) { relSetup, err := filepath.Rel(projectRoot, setupNested) if err != nil { @@ -553,6 +586,24 @@ func TestResolveGlobalRoots(t *testing.T) { } }) + t.Run("explicit setup does not bypass malformed project gitfile", func(t *testing.T) { + malformedProject := filepath.Join(launchDir, "malformed-project") + if err := os.MkdirAll(malformedProject, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(malformedProject, ".git"), []byte("not a gitdir\n"), 0o644); err != nil { + t.Fatal(err) + } + + _, err := ResolveGlobalRoots(GlobalRootOptions{ + Directory: malformedProject, + SetupRoot: setupNested, + }, launchDir) + if err == nil || !strings.Contains(err.Error(), "resolve linked worktree setup") { + t.Fatalf("ResolveGlobalRoots() error = %v, want malformed project gitfile rejection", err) + } + }) + t.Run("symlinked codemap storage is rejected", func(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("symlinks may require elevated privileges") @@ -584,3 +635,12 @@ 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/cmd/serve.go b/cmd/serve.go index 1ad6748..07b555b 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -7,7 +7,6 @@ import ( "fmt" "net/http" "os" - "path/filepath" "strings" "time" @@ -30,7 +29,12 @@ func RunServe(args []string, root string) { root = fs.Arg(0) } - absRoot, err := filepath.Abs(root) + absRoot, _, err := ResolveNearestGitRoot(root) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + absRoot, err = ValidateProjectPath(absRoot) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) diff --git a/cmd/setup.go b/cmd/setup.go index c029392..48b9ba2 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -15,6 +15,7 @@ import ( "codemap/config" "codemap/internal/buildinfo" + "codemap/internal/projectpath" "github.com/pelletier/go-toml/v2" ) @@ -99,7 +100,7 @@ func defaultIntegrationExecutable() string { } func generatedClaudeHooks(executable string) []claudeHookSpec { - command := quoteHookExecutable(executable, runtime.GOOS) + command := managedHookCommand(executable) return []claudeHookSpec{ {Event: "SessionStart", Command: command + " hook session-start --integration=claude-setup"}, {Event: "PreToolUse", Matcher: "Edit|Write", Command: command + " hook pre-edit --integration=claude-setup"}, @@ -111,7 +112,7 @@ func generatedClaudeHooks(executable string) []claudeHookSpec { } func generatedCodexHooks(executable string) []claudeHookSpec { - command := quoteHookExecutable(executable, runtime.GOOS) + command := managedHookCommand(executable) return []claudeHookSpec{ {Event: "SessionStart", Command: command + " hook session-start --agent=codex --integration=codex-setup"}, {Event: "PreToolUse", Matcher: "apply_patch|Edit|Write", Command: command + " hook pre-edit --agent=codex --integration=codex-setup"}, @@ -150,6 +151,14 @@ func detectInstalledAgents() (claude, codex bool) { return claude, codex } +func managedHookCommand(executable string) string { + command := quoteHookExecutable(executable, runtime.GOOS) + if setupRoot := projectpath.ConfiguredSetupRoot(); setupRoot != "" { + command += " --setup-root " + quoteHookExecutable(setupRoot, runtime.GOOS) + } + return command +} + // RunSetup configures codemap for the recommended hooks-first workflow. // // By default it creates: @@ -206,7 +215,12 @@ func RunSetup(args []string, defaultRoot string) int { if fs.NArg() == 1 { root = fs.Arg(0) } - absRoot, err := filepath.Abs(root) + absRoot, foundRepoRoot, err := ResolveNearestGitRoot(root) + if err != nil { + fmt.Fprintf(os.Stderr, "Error resolving path: %v\n", err) + return 1 + } + absRoot, err = ValidateProjectPath(absRoot) if err != nil { fmt.Fprintf(os.Stderr, "Error resolving path: %v\n", err) return 1 @@ -218,7 +232,7 @@ func RunSetup(args []string, defaultRoot string) int { } if !*skipConfig { - if _, err := os.Stat(filepath.Join(absRoot, ".git")); os.IsNotExist(err) { + if !foundRepoRoot { fmt.Fprintf(os.Stderr, "Warning: %s is not a git repository root; continuing setup anyway.\n", absRoot) } } @@ -265,7 +279,7 @@ func RunSetup(args []string, defaultRoot string) int { } if !*skipMCP { if configureMCP("Claude", claudeMCPPath, func(path string) (bool, error) { - return ensureClaudeMCPWithExecutable(path, executable) + return ensureClaudeMCPWithExecutable(path, executable, absRoot) }, absRoot, *useGlobalHooks) != nil { failed = true } else if !*useGlobalHooks { @@ -280,7 +294,7 @@ func RunSetup(args []string, defaultRoot string) int { failed = true } if !*skipMCP && configureMCP("Codex", codexConfigPath, func(path string) (bool, error) { - return ensureCodexMCPWithExecutable(path, executable) + return ensureCodexMCPWithExecutable(path, executable, absRoot) }, absRoot, *useGlobalHooks) != nil { failed = true } @@ -400,7 +414,7 @@ func codexConfigPath(projectRoot string, global bool) (string, error) { return filepath.Join(homeDir, ".codex", "config.toml"), nil } -func ensureClaudeMCPWithExecutable(path, executable string) (bool, error) { +func ensureClaudeMCPWithExecutable(path, executable, projectRoot string) (bool, error) { payload := map[string]any{} data, err := os.ReadFile(path) switch { @@ -434,14 +448,14 @@ func ensureClaudeMCPWithExecutable(path, executable string) (bool, error) { return false, fmt.Errorf("%s already defines a conflicting codemap MCP server", path) } serverMap := server.(map[string]any) - args := managedMCPArgs(buildinfo.Current(), "claude-setup") + args := managedMCPArgs(projectRoot, buildinfo.Current(), "claude-setup") if serverMap["command"] == executable && stringSlicesEqual(mcpServerArgs(serverMap), args) { return false, nil } serverMap["command"] = executable serverMap["args"] = args } else { - servers["codemap"] = map[string]any{"command": executable, "args": managedMCPArgs(buildinfo.Current(), "claude-setup")} + servers["codemap"] = map[string]any{"command": executable, "args": managedMCPArgs(projectRoot, buildinfo.Current(), "claude-setup")} } if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return false, err @@ -453,7 +467,7 @@ func ensureClaudeMCPWithExecutable(path, executable string) (bool, error) { return true, writeFileAtomic(path, append(out, '\n'), 0o644) } -func ensureCodexMCPWithExecutable(path, executable string) (bool, error) { +func ensureCodexMCPWithExecutable(path, executable, projectRoot string) (bool, error) { data, err := os.ReadFile(path) if err != nil && !os.IsNotExist(err) { return false, fmt.Errorf("read %s: %w", path, err) @@ -474,7 +488,7 @@ func ensureCodexMCPWithExecutable(path, executable string) (bool, error) { return false, fmt.Errorf("%s already defines a conflicting codemap MCP server", path) } serverMap := server.(map[string]any) - args := managedMCPArgs(buildinfo.Current(), "codex-setup") + args := managedMCPArgs(projectRoot, buildinfo.Current(), "codex-setup") if serverMap["command"] == executable && stringSlicesEqual(mcpServerArgs(serverMap), args) { return false, nil } @@ -486,7 +500,7 @@ func ensureCodexMCPWithExecutable(path, executable string) (bool, error) { } } const section = "[mcp_servers.codemap]" - body := section + "\ncommand = " + tomlString(executable) + "\nargs = " + tomlStringArray(managedMCPArgs(buildinfo.Current(), "codex-setup")) + "\n" + body := section + "\ncommand = " + tomlString(executable) + "\nargs = " + tomlStringArray(managedMCPArgs(projectRoot, buildinfo.Current(), "codex-setup")) + "\n" addition := "\n" + body if len(data) == 0 { addition = body @@ -520,7 +534,11 @@ func isOwnedCodemapMCPServer(raw any, integration string) bool { if command == "codemap" && stringSlicesEqual(args, []string{"mcp"}) { return true } - return isAbsoluteIntegrationPath(command) && len(args) == 5 && args[0] == "mcp" && args[1] == "--configured-version" && args[2] != "" && args[3] == "--integration" && args[4] == integration + if !isAbsoluteIntegrationPath(command) { + return false + } + _, remaining, err := ParseGlobalRootOptions(args) + return err == nil && len(remaining) == 5 && remaining[0] == "mcp" && remaining[1] == "--configured-version" && remaining[2] != "" && remaining[3] == "--integration" && remaining[4] == integration } func ensureClaudeHooks(settingsPath string, global bool) (ensureHooksResult, error) { diff --git a/cmd/setup_review_test.go b/cmd/setup_review_test.go index d326ef2..0db80db 100644 --- a/cmd/setup_review_test.go +++ b/cmd/setup_review_test.go @@ -23,7 +23,7 @@ func TestEnsureClaudeMCPPreservesLargeIntegersAndUnknownKeys(t *testing.T) { t.Fatal(err) } - changed, err := ensureClaudeMCPWithExecutable(path, filepath.Join(dir, "codemap")) + changed, err := ensureClaudeMCPWithExecutable(path, filepath.Join(dir, "codemap"), dir) if err != nil { t.Fatalf("ensureClaudeMCPWithExecutable: %v", err) } diff --git a/cmd/setup_run_test.go b/cmd/setup_run_test.go index 50965da..37d0022 100644 --- a/cmd/setup_run_test.go +++ b/cmd/setup_run_test.go @@ -101,3 +101,39 @@ func TestRunSetupCreatesConfigAndHooks(t *testing.T) { t.Fatal("expected hooks to be configured") } } + +func TestRunSetupUsesNearestGitRootFromNestedDirectory(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, ".git"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "main.go"), []byte("package main\n"), 0o644); err != nil { + t.Fatal(err) + } + + nested := filepath.Join(root, "pkg", "feature") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatal(err) + } + + var code int + out := captureOutput(func() { + code = RunSetup([]string{"--no-hooks"}, nested) + }) + if code != 0 { + t.Fatalf("RunSetup() exit code = %d, want 0", code) + } + wantRoot, err := filepath.EvalSymlinks(root) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "Project: "+wantRoot) { + t.Fatalf("expected setup output to use repo root %q, got:\n%s", wantRoot, out) + } + if _, err := os.Stat(filepath.Join(root, ".codemap", "config.json")); err != nil { + t.Fatalf("expected root config to exist: %v", err) + } + if _, err := os.Stat(filepath.Join(nested, ".codemap", "config.json")); !os.IsNotExist(err) { + t.Fatalf("expected nested config to be absent, got err=%v", err) + } +} diff --git a/cmd/setup_test.go b/cmd/setup_test.go index 00b627a..388ee20 100644 --- a/cmd/setup_test.go +++ b/cmd/setup_test.go @@ -4,9 +4,46 @@ import ( "encoding/json" "os" "path/filepath" + "runtime" + "strings" "testing" + + "codemap/internal/projectpath" ) +func TestGeneratedIntegrationsPreserveSetupRoot(t *testing.T) { + projectRoot := t.TempDir() + setupRoot := t.TempDir() + projectpath.SetSetupRoot(setupRoot) + t.Cleanup(projectpath.ResetSetupRoot) + + wantOption := "--setup-root " + quoteHookExecutable(setupRoot, runtime.GOOS) + for _, hooks := range [][]claudeHookSpec{ + generatedClaudeHooks("/tmp/codemap"), + generatedCodexHooks("/tmp/codemap"), + } { + for _, hook := range hooks { + if !strings.Contains(hook.Command, wantOption) { + t.Fatalf("hook command %q does not contain %q", hook.Command, wantOption) + } + } + } + + wantArgs := []string{ + "--project-root", projectRoot, + "--setup-root", setupRoot, + "mcp", "--configured-version", "test", "--integration", "codex-setup", + } + gotArgs := managedMCPArgs(projectRoot, "test", "codex-setup") + if !stringSlicesEqual(gotArgs, wantArgs) { + t.Fatalf("managedMCPArgs() = %#v, want %#v", gotArgs, wantArgs) + } + server := map[string]any{"command": "/tmp/codemap", "args": gotArgs} + if !isOwnedCodemapMCPServer(server, "codex-setup") { + t.Fatal("setup-root MCP command should remain managed") + } +} + func TestEnsureClaudeHooksCreatesSettings(t *testing.T) { settingsPath := filepath.Join(t.TempDir(), ".claude", "settings.local.json") diff --git a/cmd/skill.go b/cmd/skill.go index 75676ab..097e48d 100644 --- a/cmd/skill.go +++ b/cmd/skill.go @@ -3,13 +3,22 @@ package cmd import ( "fmt" "os" + "path/filepath" "strings" + "codemap/internal/projectpath" "codemap/skills" ) // RunSkill handles the "codemap skill" subcommand. func RunSkill(args []string, root string) { + resolvedRoot, _, err := ResolveNearestGitRoot(root) + if err != nil { + fmt.Fprintf(os.Stderr, "Error resolving path: %v\n", err) + os.Exit(1) + } + root = resolvedRoot + subCmd := "" if len(args) > 0 { subCmd = args[0] @@ -89,7 +98,7 @@ func runSkillShow(root, name string) { } func runSkillInit(root string) { - skillsDir := root + "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/.codemap/skills" + skillsDir := filepath.Join(projectpath.CodemapDir(root), "skills") if err := os.MkdirAll(skillsDir, 0755); err != nil { fmt.Fprintf(os.Stderr, "Error creating skills directory: %v\n", err) os.Exit(1) @@ -118,7 +127,7 @@ Describe the situations where this skill applies. 3. Third step ` - path := skillsDir + "/my-skill.md" + path := filepath.Join(skillsDir, "my-skill.md") if _, err := os.Stat(path); err == nil { fmt.Printf("Skill template already exists at %s\n", path) return diff --git a/cmd/skill_test.go b/cmd/skill_test.go index b36acc9..d58e394 100644 --- a/cmd/skill_test.go +++ b/cmd/skill_test.go @@ -5,6 +5,8 @@ import ( "path/filepath" "strings" "testing" + + "codemap/internal/projectpath" ) func TestRunSkill_PrintsUsageForUnknownOrMissingSubcommand(t *testing.T) { @@ -128,3 +130,20 @@ func TestRunSkillInit_CreatesTemplateAndIsIdempotent(t *testing.T) { t.Fatalf("expected template content in %s, got:\n%s", path, string(data)) } } + +func TestRunSkillInitUsesSetupRoot(t *testing.T) { + projectRoot := t.TempDir() + setupRoot := t.TempDir() + projectpath.SetSetupRoot(setupRoot) + t.Cleanup(projectpath.ResetSetupRoot) + + captureOutput(func() { runSkillInit(projectRoot) }) + want := filepath.Join(setupRoot, ".codemap", "skills", "my-skill.md") + if _, err := os.Stat(want); err != nil { + t.Fatalf("setup-root skill template missing: %v", err) + } + projectPath := filepath.Join(projectRoot, ".codemap", "skills", "my-skill.md") + if _, err := os.Stat(projectPath); !os.IsNotExist(err) { + t.Fatalf("project-root skill template unexpectedly exists: %v", err) + } +} diff --git a/docs/HOOKS.md b/docs/HOOKS.md index fa1f316..09178df 100644 --- a/docs/HOOKS.md +++ b/docs/HOOKS.md @@ -43,7 +43,29 @@ Use `--agent claude` or `--agent codex` to configure only one integration. Managed commands use the verified absolute path of the running `codemap`; rerun setup if that path changes. `codemap doctor` validates without rewriting; it checks project scope and falls back to user scope, reporting which one satisfied each check (`codemap doctor --global` checks user scope only). -Important: run `codemap setup` from the git repo root. Hook commands run relative to the current working directory; starting Claude from a nested folder can prevent codemap from finding `.git` and `.codemap`. +Important: repo-scoped commands and managed hooks resolve the nearest git root +automatically, so starting Claude or Codex from a nested folder still finds +root-level setup and hook state. + +Standard linked Git worktrees automatically inherit central config and project +skills from their primary worktree. Create one with Git, an IDE, or any manager +that writes standard linked-worktree metadata, then give the agent its absolute +path: + +```bash +git worktree add -b +codemap -C /tmp/feature-worktree context +``` + +Normal CLI, hook, and plugin MCP calls need no `--setup-root`. Config and skills +come from the primary worktree, while daemon state, events, handoffs, and +session files remain local to the linked worktree. Tools built on standard +linked worktrees require no special Codemap integration. + +Independent clones remain unrelated. Use `--setup-root /path/to/original` +explicitly when they must share both policy and runtime state. `-C` and +`--setup-root` may name repository roots or subdirectories; managed hook, MCP, +and daemon commands preserve an explicit setup override. ### Manual Hook JSON (advanced) @@ -120,7 +142,14 @@ If you want to manage Claude settings manually, add this `hooks` object to `.cla Restart Claude Code. You should immediately see project context at session start. -If you intentionally run Claude from subdirectories, pass the repo root explicitly: +To preserve setup explicitly in a manually managed hook command: + +```bash +codemap --setup-root /path/to/original hook session-start +``` + +If you intentionally want a hook to target a different repository than the +current nearest git root, pass that root explicitly: ```bash codemap hook session-start "$(git rev-parse --show-toplevel)" diff --git a/docs/MCP.md b/docs/MCP.md index 13d27b9..8932d96 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -68,6 +68,43 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`: If you prefer a standalone MCP binary, keep using `/path/to/codemap-mcp`. +### Linked Worktrees and Separate Roots + +A standard linked Git worktree automatically reuses `.codemap/config.json` and +project skills from its primary worktree. Create it with Git, an IDE, or any +compatible worktree manager, then pass its absolute path to normal MCP tool +calls: + +```bash +git worktree add -b +``` + +No MCP launch flags, project-specific MCP stanza, or `--setup-root` is needed. +Handoff, watcher, and hook/session state stays under the linked worktree's own +`.codemap` directory, so parallel worktrees do not overwrite one another. + +Independent clones cannot prove that they should share setup. For those, put +an explicit setup override before `mcp`: + +```json +{ + "mcpServers": { + "codemap": { + "command": "codemap", + "args": [ + "--project-root", "/tmp/independent-clone", + "--setup-root", "/path/to/original", + "mcp" + ] + } + } +} +``` + +Both values may be repository roots or subdirectories. An explicit +`--setup-root` preserves the existing shared policy-and-runtime-state behavior, +and managed MCP definitions created while it is active retain it automatically. + ## Available Tools (17) ### Project Analysis diff --git a/internal/projectpath/path.go b/internal/projectpath/path.go new file mode 100644 index 0000000..bed3a5f --- /dev/null +++ b/internal/projectpath/path.go @@ -0,0 +1,341 @@ +// Package projectpath separates the analyzed project from its Codemap setup. +package projectpath + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" +) + +// Source identifies how Codemap selected setup and runtime storage. +type Source string + +const ( + SourceExplicit Source = "explicit" + SourceLinkedWorktree Source = "linked-worktree" + SourceProject Source = "project" +) + +// Selection contains the roots Codemap uses for one analyzed project. +type Selection struct { + ProjectRoot string + SetupRoot string + RuntimeRoot string + Source Source +} + +var configuredSetupRoot struct { + sync.RWMutex + path string +} + +// Select resolves setup policy and mutable runtime storage for one project. +func Select(projectRoot string) (Selection, error) { + root, err := canonicalProjectRoot(projectRoot) + if err != nil { + return Selection{}, fmt.Errorf("resolve analyzed project %q: %w", projectRoot, err) + } + if explicit := ConfiguredSetupRoot(); explicit != "" { + explicit = filepath.Clean(explicit) + return Selection{ProjectRoot: root, SetupRoot: explicit, RuntimeRoot: explicit, Source: SourceExplicit}, nil + } + + primary, found, err := linkedWorktreeSetupRoot(root) + if err != nil { + return Selection{}, fmt.Errorf("resolve linked worktree setup for analyzed project %q: %w", root, err) + } + if err := validateRuntimeStorage(root); err != nil { + return Selection{}, err + } + if found { + return Selection{ProjectRoot: root, SetupRoot: primary, RuntimeRoot: root, Source: SourceLinkedWorktree}, nil + } + return Selection{ProjectRoot: root, SetupRoot: root, RuntimeRoot: root, Source: SourceProject}, nil +} + +func validateRuntimeStorage(projectRoot string) error { + dir := filepath.Join(projectRoot, ".codemap") + info, err := os.Lstat(dir) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return fmt.Errorf("access runtime Codemap storage %q for analyzed project %q: %w", dir, projectRoot, err) + } + if !info.IsDir() { + return fmt.Errorf("unsafe Codemap storage %q for analyzed project %q: expected a real directory", dir, projectRoot) + } + return nil +} + +// SetupRoot returns the configured or automatically discovered setup root. +func SetupRoot(projectRoot string) string { + if explicit := ConfiguredSetupRoot(); explicit != "" { + return filepath.Clean(explicit) + } + selection, err := Select(projectRoot) + if err == nil { + return selection.SetupRoot + } + return filepath.Clean(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 containing reusable project policy. +func CodemapDir(projectRoot string) string { + return filepath.Join(SetupRoot(projectRoot), ".codemap") +} + +// 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 + } + return filepath.Clean(projectRoot) +} + +// RuntimeCodemapDir returns the .codemap directory for mutable project state. +func RuntimeCodemapDir(projectRoot string) string { + return filepath.Join(RuntimeRoot(projectRoot), ".codemap") +} + +func canonicalProjectRoot(root string) (string, error) { + absRoot, err := filepath.Abs(root) + if err != nil { + return "", err + } + canonicalRoot, err := filepath.EvalSymlinks(absRoot) + if err != nil { + return "", err + } + info, err := os.Stat(canonicalRoot) + if err != nil { + return "", err + } + if !info.IsDir() { + return "", fmt.Errorf("expected a directory") + } + for current := canonicalRoot; ; current = filepath.Dir(current) { + marker := filepath.Join(current, ".git") + markerInfo, markerErr := os.Lstat(marker) + if markerErr == nil { + if markerInfo.IsDir() || markerInfo.Mode().IsRegular() { + return current, nil + } + return "", fmt.Errorf("invalid Git marker %q: expected a directory or regular gitfile", marker) + } + if !os.IsNotExist(markerErr) { + return "", markerErr + } + parent := filepath.Dir(current) + if parent == current { + return canonicalRoot, nil + } + } +} + +func linkedWorktreeSetupRoot(projectRoot string) (string, bool, error) { + gitFile := filepath.Join(projectRoot, ".git") + info, err := os.Lstat(gitFile) + if os.IsNotExist(err) { + return "", false, nil + } + if err != nil { + return "", false, err + } + if info.IsDir() { + return "", false, nil + } + if !info.Mode().IsRegular() { + return "", false, fmt.Errorf("invalid Git marker %q: expected a directory or regular gitfile", gitFile) + } + + gitDirValue, err := readMetadataPath(gitFile, "gitdir:") + if err != nil { + return "", false, err + } + gitDir := resolveMetadataPath(filepath.Dir(gitFile), gitDirValue) + if err := requireRealDirectory(gitDir); err != nil { + return "", false, fmt.Errorf("invalid gitdir %q: %w", gitDir, err) + } + gitDir, err = filepath.EvalSymlinks(gitDir) + if err != nil { + return "", false, fmt.Errorf("resolve gitdir %q: %w", gitDir, err) + } + + commonFile := filepath.Join(gitDir, "commondir") + if _, err := os.Lstat(commonFile); os.IsNotExist(err) { + if isStandardSubmoduleGitDir(gitDir) { + return "", false, nil + } + return "", false, err + } else if err != nil { + return "", false, err + } + commonValue, err := readMetadataPath(commonFile, "") + if err != nil { + return "", false, err + } + commonDir := resolveMetadataPath(gitDir, commonValue) + if filepath.Base(commonDir) != ".git" { + return "", false, fmt.Errorf("commondir %q is not a normal primary .git directory", commonDir) + } + if err := requireRealDirectory(commonDir); err != nil { + return "", false, fmt.Errorf("invalid commondir %q: %w", commonDir, err) + } + commonDir, err = filepath.EvalSymlinks(commonDir) + if err != nil { + return "", false, fmt.Errorf("resolve commondir %q: %w", commonDir, err) + } + if !pathWithin(filepath.Join(commonDir, "worktrees"), gitDir) { + return "", false, fmt.Errorf("gitdir %q escapes standard worktree metadata %q", gitDir, filepath.Join(commonDir, "worktrees")) + } + + primary := filepath.Dir(commonDir) + setupDir := filepath.Join(primary, ".codemap") + setupInfo, err := os.Lstat(setupDir) + if os.IsNotExist(err) { + return "", false, nil + } + if err != nil { + return "", false, fmt.Errorf("access primary setup %q for analyzed project %q: %w", setupDir, projectRoot, err) + } + if !setupInfo.IsDir() { + return "", false, fmt.Errorf("unsafe Codemap storage %q for analyzed project %q: expected a real directory", setupDir, projectRoot) + } + setup, err := os.Open(setupDir) + if err != nil { + return "", false, fmt.Errorf("access primary setup %q for analyzed project %q: %w", setupDir, projectRoot, err) + } + _, readErr := setup.Readdirnames(1) + closeErr := setup.Close() + if readErr != nil && readErr != io.EOF { + return "", false, fmt.Errorf("access primary setup %q for analyzed project %q: %w", setupDir, projectRoot, readErr) + } + if closeErr != nil { + return "", false, fmt.Errorf("access primary setup %q for analyzed project %q: %w", setupDir, projectRoot, closeErr) + } + return primary, true, nil +} + +func isStandardSubmoduleGitDir(gitDir string) bool { + modulesRoot := "" + for current := gitDir; ; current = filepath.Dir(current) { + if filepath.Base(current) == "modules" && filepath.Base(filepath.Dir(current)) == ".git" { + modulesRoot = current + break + } + parent := filepath.Dir(current) + if parent == current { + return false + } + } + rel, err := filepath.Rel(modulesRoot, gitDir) + if err != nil || rel == "." || rel == ".." || filepath.IsAbs(rel) || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return false + } + head, err := os.Lstat(filepath.Join(gitDir, "HEAD")) + if err != nil || !head.Mode().IsRegular() { + return false + } + return requireRealDirectory(filepath.Join(gitDir, "objects")) == nil +} + +func readMetadataPath(path, prefix string) (string, error) { + info, err := os.Lstat(path) + if err != nil { + return "", err + } + if !info.Mode().IsRegular() { + return "", fmt.Errorf("invalid metadata %q: expected a regular file", path) + } + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + value, err := parseMetadataPath(data, prefix) + if err != nil { + return "", fmt.Errorf("invalid metadata %q: %w", path, err) + } + return value, nil +} + +func parseMetadataPath(data []byte, prefix string) (string, error) { + value := strings.TrimSpace(string(data)) + if strings.ContainsAny(value, "\r\n\x00") { + return "", fmt.Errorf("expected one path") + } + if prefix != "" { + if !strings.HasPrefix(value, prefix) { + return "", fmt.Errorf("expected %s path", prefix) + } + value = strings.TrimSpace(strings.TrimPrefix(value, prefix)) + } + if value == "" { + return "", fmt.Errorf("empty path") + } + return value, nil +} + +func resolveMetadataPath(base, value string) string { + if filepath.IsAbs(value) { + return filepath.Clean(value) + } + return filepath.Clean(filepath.Join(base, value)) +} + +func requireRealDirectory(path string) error { + info, err := os.Lstat(path) + if err != nil { + return err + } + if !info.IsDir() { + return fmt.Errorf("expected a real directory") + } + return nil +} + +func pathWithin(parent, child string) bool { + rel, err := filepath.Rel(parent, child) + return err == nil && rel != "." && rel != ".." && filepath.Dir(rel) == "." && !filepath.IsAbs(rel) +} diff --git a/internal/projectpath/path_test.go b/internal/projectpath/path_test.go new file mode 100644 index 0000000..07058f9 --- /dev/null +++ b/internal/projectpath/path_test.go @@ -0,0 +1,345 @@ +package projectpath + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestSelectLinkedWorktreeUsesPrimarySetupAndLocalRuntime(t *testing.T) { + ResetSetupRoot() + t.Cleanup(ResetSetupRoot) + + primary, linked := makeLinkedWorktreeFixture(t, true) + + got, err := Select(linked) + if err != nil { + t.Fatalf("Select() error: %v", err) + } + if got.ProjectRoot != linked || got.SetupRoot != primary || got.RuntimeRoot != linked || got.Source != SourceLinkedWorktree { + t.Fatalf("Select() = %#v, want project/runtime %q, setup %q, source %q", got, linked, primary, SourceLinkedWorktree) + } +} + +func TestSelectDiscoversLinkedWorktreeFromNestedProjectPath(t *testing.T) { + ResetSetupRoot() + t.Cleanup(ResetSetupRoot) + + primary, linked := makeLinkedWorktreeFixture(t, true) + nested := filepath.Join(linked, "pkg", "feature") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatal(err) + } + + got, err := Select(nested) + if err != nil { + t.Fatalf("Select() error: %v", err) + } + if got.ProjectRoot != linked || got.SetupRoot != primary || got.RuntimeRoot != linked || got.Source != SourceLinkedWorktree { + t.Fatalf("Select() = %#v, want project/runtime %q, setup %q, source %q", got, linked, primary, SourceLinkedWorktree) + } +} + +func TestParseMetadataPathPreservesWindowsGitdir(t *testing.T) { + want := `C:\Users\agent\repo\.git\worktrees\feature` + got, err := parseMetadataPath([]byte("gitdir: "+want+"\r\n"), "gitdir:") + if err != nil { + t.Fatalf("parseMetadataPath() error: %v", err) + } + if got != want { + t.Fatalf("parseMetadataPath() = %q, want %q", got, want) + } +} + +func TestSelectPrecedenceFallbackAndSafety(t *testing.T) { + t.Run("explicit override wins linked discovery", func(t *testing.T) { + primary, linked := makeLinkedWorktreeFixture(t, true) + explicit := t.TempDir() + SetSetupRoot(explicit) + t.Cleanup(ResetSetupRoot) + + got, err := Select(linked) + if err != nil { + t.Fatalf("Select() error: %v", err) + } + if got.ProjectRoot != linked || got.SetupRoot != explicit || got.RuntimeRoot != explicit || got.Source != SourceExplicit { + t.Fatalf("Select() = %#v, want explicit root %q (primary was %q)", got, explicit, primary) + } + }) + + t.Run("linked worktree without primary setup remains project local", func(t *testing.T) { + ResetSetupRoot() + _, linked := makeLinkedWorktreeFixture(t, false) + got, err := Select(linked) + if err != nil { + t.Fatalf("Select() error: %v", err) + } + if got.SetupRoot != linked || got.RuntimeRoot != linked || got.Source != SourceProject { + t.Fatalf("Select() = %#v, want project-local selection", got) + } + }) + + t.Run("primary worktree remains project local", func(t *testing.T) { + ResetSetupRoot() + primary := t.TempDir() + if err := os.MkdirAll(filepath.Join(primary, ".git"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(primary, ".codemap"), 0o755); err != nil { + t.Fatal(err) + } + primary, _ = filepath.EvalSymlinks(primary) + got, err := Select(primary) + if err != nil { + t.Fatalf("Select() error: %v", err) + } + if got.SetupRoot != primary || got.RuntimeRoot != primary || got.Source != SourceProject { + t.Fatalf("Select() = %#v, want primary project-local selection", got) + } + }) + + t.Run("one process selects each linked project independently", func(t *testing.T) { + ResetSetupRoot() + primaryA, linkedA := makeLinkedWorktreeFixture(t, true) + primaryB, linkedB := makeLinkedWorktreeFixture(t, true) + gotA, err := Select(linkedA) + if err != nil { + t.Fatalf("Select(A) error: %v", err) + } + gotB, err := Select(linkedB) + if err != nil { + t.Fatalf("Select(B) error: %v", err) + } + if gotA.SetupRoot != primaryA || gotB.SetupRoot != primaryB || gotA.SetupRoot == gotB.SetupRoot { + t.Fatalf("Select() roots = %q, %q; want independent %q, %q", gotA.SetupRoot, gotB.SetupRoot, primaryA, primaryB) + } + }) + + t.Run("malformed gitfile fails closed", func(t *testing.T) { + ResetSetupRoot() + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, ".git"), []byte("not a gitdir\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := Select(root); err == nil || !strings.Contains(err.Error(), "expected gitdir:") { + t.Fatalf("Select() error = %v, want bounded malformed-gitfile error", err) + } + }) + + t.Run("symlinked primary storage fails closed", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlinks may require elevated privileges") + } + ResetSetupRoot() + primary, linked := makeLinkedWorktreeFixture(t, false) + if err := os.Symlink(t.TempDir(), filepath.Join(primary, ".codemap")); err != nil { + t.Fatal(err) + } + if _, err := Select(linked); err == nil || !strings.Contains(err.Error(), "unsafe Codemap storage") { + t.Fatalf("Select() error = %v, want unsafe-storage error", err) + } + }) + + t.Run("symlinked linked runtime storage fails closed", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlinks may require elevated privileges") + } + ResetSetupRoot() + _, linked := makeLinkedWorktreeFixture(t, true) + if err := os.Symlink(t.TempDir(), filepath.Join(linked, ".codemap")); err != nil { + t.Fatal(err) + } + if _, err := Select(linked); err == nil || !strings.Contains(err.Error(), "unsafe Codemap storage") { + t.Fatalf("Select() error = %v, want unsafe runtime-storage error", err) + } + }) + + t.Run("symlinked linked runtime without primary setup fails closed", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlinks may require elevated privileges") + } + ResetSetupRoot() + _, linked := makeLinkedWorktreeFixture(t, false) + if err := os.Symlink(t.TempDir(), filepath.Join(linked, ".codemap")); err != nil { + t.Fatal(err) + } + if _, err := Select(linked); err == nil || !strings.Contains(err.Error(), "unsafe Codemap storage") { + t.Fatalf("Select() error = %v, want unsafe project-local storage error", err) + } + }) + + t.Run("symlinked ordinary project storage fails closed", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlinks may require elevated privileges") + } + ResetSetupRoot() + project := t.TempDir() + if err := os.MkdirAll(filepath.Join(project, ".git"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(t.TempDir(), filepath.Join(project, ".codemap")); err != nil { + t.Fatal(err) + } + if _, err := Select(project); err == nil || !strings.Contains(err.Error(), "unsafe Codemap storage") { + t.Fatalf("Select() error = %v, want unsafe project-local storage error", err) + } + }) +} + +func TestRuntimeCodemapDirSeparatesAutomaticAndExplicitStorage(t *testing.T) { + ResetSetupRoot() + primary, linked := makeLinkedWorktreeFixture(t, true) + if got, want := CodemapDir(linked), filepath.Join(primary, ".codemap"); got != want { + t.Fatalf("CodemapDir() = %q, want policy dir %q", got, want) + } + if got, want := RuntimeCodemapDir(linked), filepath.Join(linked, ".codemap"); got != want { + t.Fatalf("RuntimeCodemapDir() = %q, want local runtime dir %q", got, want) + } + + explicit := t.TempDir() + SetSetupRoot(explicit) + t.Cleanup(ResetSetupRoot) + if got, want := RuntimeCodemapDir(linked), filepath.Join(explicit, ".codemap"); got != want { + t.Fatalf("explicit RuntimeCodemapDir() = %q, want shared runtime dir %q", got, want) + } +} + +func TestSelectRejectsNonstandardWorktreeMetadataDirectory(t *testing.T) { + ResetSetupRoot() + t.Cleanup(ResetSetupRoot) + primary := t.TempDir() + worktreesDir := filepath.Join(primary, ".git", "worktrees") + if err := os.MkdirAll(worktreesDir, 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(worktreesDir, "commondir"), []byte("..\n"), 0o644); err != nil { + t.Fatal(err) + } + linked := t.TempDir() + if err := os.WriteFile(filepath.Join(linked, ".git"), []byte("gitdir: "+worktreesDir+"\n"), 0o644); err != nil { + t.Fatal(err) + } + + if _, err := Select(linked); err == nil || !strings.Contains(err.Error(), "standard worktree metadata") { + t.Fatalf("Select() error = %v, want nonstandard-metadata rejection", err) + } +} + +func TestSelectRejectsWorktreeMetadataWithoutCommondir(t *testing.T) { + ResetSetupRoot() + t.Cleanup(ResetSetupRoot) + primary := t.TempDir() + gitDir := filepath.Join(primary, ".git", "worktrees", "broken") + if err := os.MkdirAll(gitDir, 0o755); err != nil { + t.Fatal(err) + } + linked := t.TempDir() + if err := os.WriteFile(filepath.Join(linked, ".git"), []byte("gitdir: "+gitDir+"\n"), 0o644); err != nil { + t.Fatal(err) + } + + if _, err := Select(linked); err == nil || !strings.Contains(err.Error(), "commondir") { + t.Fatalf("Select() error = %v, want missing-commondir rejection", err) + } +} + +func TestSelectReportsInaccessiblePrimarySetup(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("directory permissions are platform-specific") + } + ResetSetupRoot() + t.Cleanup(ResetSetupRoot) + primary, linked := makeLinkedWorktreeFixture(t, true) + setupDir := filepath.Join(primary, ".codemap") + if err := os.Chmod(setupDir, 0); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(setupDir, 0o755) }) + + if _, err := Select(linked); err == nil || !strings.Contains(err.Error(), linked) || !strings.Contains(err.Error(), setupDir) { + t.Fatalf("Select() error = %v, want analyzed project and inaccessible setup paths", err) + } +} + +func makeLinkedWorktreeFixture(t *testing.T, withSetup bool) (primary, linked string) { + t.Helper() + primary = t.TempDir() + gitDir := filepath.Join(primary, ".git", "worktrees", "agent") + if err := os.MkdirAll(gitDir, 0o755); err != nil { + t.Fatal(err) + } + if withSetup { + 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 = t.TempDir() + if err := os.WriteFile(filepath.Join(linked, ".git"), []byte("gitdir: "+gitDir+"\n"), 0o644); err != nil { + t.Fatal(err) + } + linked, _ = filepath.EvalSymlinks(linked) + primary, _ = filepath.EvalSymlinks(primary) + return primary, linked +} + +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) { + SetSetupRoot("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/setup/root") + t.Cleanup(ResetSetupRoot) + + got := PrependSetupRootArgs("watch", "start", "/project") + want := []string{"--setup-root", "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/setup/root", "watch", "start", "/project"} + 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) + } + } +} diff --git a/main.go b/main.go index 37ebfad..b7f4ec0 100644 --- a/main.go +++ b/main.go @@ -18,6 +18,7 @@ import ( "codemap/config" "codemap/handoff" "codemap/internal/buildinfo" + "codemap/internal/projectpath" "codemap/limits" "codemap/render" "codemap/scanner" @@ -46,6 +47,13 @@ var ( ) func main() { + args, err := applyGlobalRootOptions(os.Args[1:]) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(2) + } + os.Args = append([]string{os.Args[0]}, args...) + if len(os.Args) >= 2 && (os.Args[1] == "version" || os.Args[1] == "--version" || os.Args[1] == "-version") { fmt.Printf("codemap %s\n", buildinfo.Current()) return @@ -111,7 +119,7 @@ func main() { return } - // Handle "config" subcommand before global flag parsing + // Handle "config" subcommand before default analysis flag parsing. if len(os.Args) >= 2 && os.Args[1] == "config" { subCmd := "" if len(os.Args) >= 3 { @@ -125,7 +133,7 @@ func main() { return } - // Handle "setup" subcommand before global flag parsing + // Handle "setup" subcommand before default analysis flag parsing. if len(os.Args) >= 2 && os.Args[1] == "setup" { root, _ := os.Getwd() if code := cmd.RunSetup(os.Args[2:], root); code != 0 { @@ -142,7 +150,7 @@ func main() { return } - // Handle "mcp" subcommand before global flag parsing + // Handle "mcp" subcommand before default analysis flag parsing. if len(os.Args) >= 2 && os.Args[1] == "mcp" { if code := cmd.RunMCP(os.Args[2:]); code != 0 { os.Exit(code) @@ -150,7 +158,7 @@ func main() { return } - // Handle "skill" subcommand before global flag parsing + // Handle "skill" subcommand before default analysis flag parsing. if len(os.Args) >= 2 && os.Args[1] == "skill" { root, _ := os.Getwd() cmd.RunSkill(os.Args[2:], root) @@ -163,7 +171,7 @@ func main() { return } - // Handle "context" subcommand before global flag parsing + // Handle "context" subcommand before default analysis flag parsing. if len(os.Args) >= 2 && os.Args[1] == "context" { root, _ := os.Getwd() cmd.RunContext(os.Args[2:], root) @@ -216,6 +224,8 @@ func main() { fmt.Println("Options:") fmt.Println(" --help Show this help message") fmt.Println(" --version Show build version") + fmt.Println(" -C, --project-root Operate on code in .") + fmt.Println(" --setup-root Reuse state from /.codemap.") fmt.Println(" --skyline City skyline visualization") fmt.Println(" --animate Animated skyline (use with --skyline)") fmt.Println(" --deps Dependency flow map (functions & imports)") @@ -313,6 +323,17 @@ func main() { fmt.Fprintf(os.Stderr, "Error getting absolute path: %v\n", err) os.Exit(1) } + // A bare word that isn't a directory is almost always a typo'd subcommand + // ("codemap drift"), so fail with directions instead of a path-resolution error. + if _, statErr := os.Stat(root); os.IsNotExist(statErr) { + fmt.Fprintf(os.Stderr, "Error: path %q does not exist.\n", root) + fmt.Fprintln(os.Stderr, "If you meant a subcommand, run 'codemap --help' for the full list.") + os.Exit(1) + } + if _, err := cmd.ValidateProjectPath(absRoot); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } // Initialize gitignore cache (supports nested .gitignore files) gitCache := scanner.NewGitIgnoreCache(root) @@ -355,7 +376,12 @@ func main() { // Watch mode - start daemon if *watchMode { - runWatchMode(absRoot, *debugMode) + resolvedRoot, _, err := cmd.ResolveNearestGitRoot(absRoot) + if err != nil { + fmt.Fprintf(os.Stderr, "Error getting watch root: %v\n", err) + os.Exit(1) + } + runWatchMode(resolvedRoot, *debugMode) return } @@ -396,14 +422,6 @@ func main() { mode = "skyline" } - // A bare word that isn't a directory is almost always a typo'd subcommand - // ("codemap drift"), so fail with directions instead of a walk error. - if _, statErr := os.Stat(root); os.IsNotExist(statErr) { - fmt.Fprintf(os.Stderr, "Error: path %q does not exist.\n", root) - fmt.Fprintln(os.Stderr, "If you meant a subcommand, run 'codemap --help' for the full list.") - os.Exit(1) - } - // Scan files files, err := scanner.ScanFiles(context.Background(), root, gitCache, only, exclude) if err != nil { @@ -448,6 +466,42 @@ func main() { } } +func applyGlobalRootOptions(args []string) ([]string, error) { + opts, remaining, err := cmd.ParseGlobalRootOptions(args) + if err != nil { + return nil, err + } + if !opts.Active() { + launchDir, err := os.Getwd() + if err != nil { + return nil, fmt.Errorf("get working directory: %w", err) + } + if _, err := projectpath.Select(launchDir); err != nil { + return nil, err + } + return remaining, nil + } + + launchDir, err := os.Getwd() + if err != nil { + return nil, fmt.Errorf("get working directory: %w", err) + } + roots, err := cmd.ResolveGlobalRoots(opts, launchDir) + if err != nil { + return nil, err + } + if err := os.Chdir(roots.Project); err != nil { + return nil, fmt.Errorf("change to project root %q: %w", roots.Project, err) + } + if opts.SetupRoot != "" { + projectpath.SetSetupRoot(roots.Setup) + } else { + projectpath.ResetSetupRoot() + } + + return remaining, nil +} + // stdinManifest is the JSON format accepted by --stdin. type stdinManifest struct { Root string `json:"root"` @@ -659,7 +713,12 @@ func runImportersMode(root, file string, jsonMode bool, filters scanner.Filters) } func runWatchSubcommand(subCmd, root string) { - absRoot, err := filepath.Abs(root) + absRoot, _, err := cmd.ResolveNearestGitRoot(root) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + absRoot, err = cmd.ValidateProjectPath(absRoot) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) @@ -677,7 +736,8 @@ func runWatchSubcommand(subCmd, root string) { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } - cmd := execCommand(exe, "watch", "daemon", absRoot) + args := projectpath.PrependSetupRootArgs("watch", "daemon", absRoot) + cmd := execCommand(exe, args...) cmd.Stdout = nil cmd.Stderr = nil cmd.Stdin = nil @@ -759,6 +819,10 @@ func runHandoffSubcommand(args []string) { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } + if _, err := cmd.ValidateProjectPath(absRoot); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } var artifact *handoff.Artifact if *latest { diff --git a/main_more_test.go b/main_more_test.go index d59280c..34c526b 100644 --- a/main_more_test.go +++ b/main_more_test.go @@ -15,6 +15,7 @@ import ( "codemap/analysis" "codemap/config" "codemap/handoff" + "codemap/internal/projectpath" "codemap/scanner" "codemap/watch" ) @@ -27,6 +28,128 @@ type fakeWatchProcess struct { events []watch.Event } +func TestApplyGlobalRootOptions(t *testing.T) { + launchDir := t.TempDir() + projectRoot := filepath.Join(launchDir, "worktree") + setupRoot := filepath.Join(launchDir, "original") + for _, root := range []string{projectRoot, setupRoot} { + if err := os.MkdirAll(filepath.Join(root, ".git"), 0o755); err != nil { + t.Fatal(err) + } + } + projectNested := filepath.Join(projectRoot, "pkg") + setupNested := filepath.Join(setupRoot, "cmd") + for _, dir := range []string{projectNested, setupNested} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + } + + originalDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(launchDir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(originalDir) }) + projectpath.ResetSetupRoot() + t.Cleanup(projectpath.ResetSetupRoot) + + args, err := applyGlobalRootOptions([]string{ + "context", "--project-root", projectNested, + "--setup-root", setupNested, "--compact", + }) + if err != nil { + t.Fatalf("applyGlobalRootOptions() error: %v", err) + } + if got := strings.Join(args, "|"); got != "context|--compact" { + t.Fatalf("args = %q, want context|--compact", got) + } + gotDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + wantDir, err := filepath.EvalSymlinks(projectRoot) + if err != nil { + t.Fatal(err) + } + if gotDir != wantDir { + t.Fatalf("working directory = %q, want %q", gotDir, wantDir) + } + wantSetup, err := filepath.EvalSymlinks(setupRoot) + if err != nil { + t.Fatal(err) + } + if got := projectpath.SetupRoot(projectRoot); got != wantSetup { + t.Fatalf("setup root = %q, want %q", got, wantSetup) + } +} + +func TestApplyGlobalRootOptionsDirectoryKeepsAutomaticLinkedSelection(t *testing.T) { + launchDir := t.TempDir() + primary := filepath.Join(launchDir, "primary") + 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(launchDir, "linked") + if err := os.MkdirAll(filepath.Join(linked, "pkg"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(linked, ".git"), []byte("gitdir: "+gitDir+"\n"), 0o644); err != nil { + t.Fatal(err) + } + + originalDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(originalDir) }) + projectpath.ResetSetupRoot() + t.Cleanup(projectpath.ResetSetupRoot) + + if _, err := applyGlobalRootOptions([]string{"-C", filepath.Join(linked, "pkg"), "context"}); err != nil { + t.Fatalf("applyGlobalRootOptions() error: %v", err) + } + if got := projectpath.ConfiguredSetupRoot(); got != "" { + t.Fatalf("ConfiguredSetupRoot() = %q, want no explicit override", got) + } + selection, err := projectpath.Select(linked) + if err != nil { + t.Fatalf("Select() error: %v", err) + } + primary, _ = filepath.EvalSymlinks(primary) + if selection.SetupRoot != primary || selection.Source != projectpath.SourceLinkedWorktree { + t.Fatalf("Select() = %#v, want automatic setup %q", selection, primary) + } +} + +func TestApplyGlobalRootOptionsRejectsMalformedLinkedMetadataWithoutFlags(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, ".git"), []byte("not a gitdir\n"), 0o644); err != nil { + t.Fatal(err) + } + originalDir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(root); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(originalDir) }) + + if _, err := applyGlobalRootOptions([]string{"context"}); err == nil || !strings.Contains(err.Error(), "resolve linked worktree setup") { + t.Fatalf("applyGlobalRootOptions() error = %v, want bounded linked-worktree error", err) + } +} + func (f *fakeWatchProcess) Start() error { f.started = true return f.startErr @@ -296,6 +419,65 @@ func TestRunWatchSubcommandMessages(t *testing.T) { } } +func TestRunWatchSubcommandUsesNearestGitRootFromNestedDirectory(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, ".git"), 0o755); err != nil { + t.Fatal(err) + } + writeMainWatchState(t, root, watch.State{ + UpdatedAt: time.Now(), + FileCount: 4, + Hubs: []string{"pkg/types.go"}, + }, true) + + nested := filepath.Join(root, "pkg", "feature") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatal(err) + } + + stdout, _ := captureMainStreams(t, func() { runWatchSubcommand("status", nested) }) + for _, check := range []string{"Watch daemon running", "Files: 4", "Hubs: 1"} { + if !strings.Contains(stdout, check) { + t.Fatalf("expected %q in nested watch status output, got:\n%s", check, stdout) + } + } +} + +func TestRunWatchStartPreservesSetupRoot(t *testing.T) { + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, ".git"), 0o755); err != nil { + t.Fatal(err) + } + setupRoot := t.TempDir() + projectpath.SetSetupRoot(setupRoot) + t.Cleanup(projectpath.ResetSetupRoot) + + var gotArgs []string + withMainRuntimeStubs( + t, + nil, + nil, + func(_ string, args ...string) *exec.Cmd { + gotArgs = append([]string(nil), args...) + return exec.Command("sh", "-c", "exit 0") + }, + func() (string, error) { return "/tmp/codemap", nil }, + func(string) bool { return false }, + nil, + nil, + ) + + captureMainStreams(t, func() { runWatchSubcommand("start", root) }) + wantRoot, err := filepath.EvalSymlinks(root) + if err != nil { + t.Fatal(err) + } + want := []string{"--setup-root", setupRoot, "watch", "daemon", wantRoot} + if strings.Join(gotArgs, "|") != strings.Join(want, "|") { + t.Fatalf("watch daemon args = %v, want %v", gotArgs, want) + } +} + func TestRunHandoffSubcommandLatestVariantsMore(t *testing.T) { root := t.TempDir() @@ -659,7 +841,10 @@ func TestRunWatchModeRunDaemonAndWatchStart(t *testing.T) { if gotName != "/tmp/codemap-test" { t.Fatalf("watch start executable = %q, want /tmp/codemap-test", gotName) } - absRoot, _ := filepath.Abs(root) + absRoot, err := filepath.EvalSymlinks(root) + if err != nil { + t.Fatal(err) + } wantArgs := []string{"watch", "daemon", absRoot} if strings.Join(gotArgs, "|") != strings.Join(wantArgs, "|") { t.Fatalf("watch start args = %v, want %v", gotArgs, wantArgs) diff --git a/main_test.go b/main_test.go index 299d2ff..39da365 100644 --- a/main_test.go +++ b/main_test.go @@ -60,6 +60,8 @@ func TestHelpFlag(t *testing.T) { "--diff", "--ref", "blast-radius", + "-C, --project-root Operate on code in .", + "--setup-root Reuse state from /.codemap.", } for _, expected := range expectedStrings { From 8696cde3360217f8751c8f847ff9c3d408077c26 Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Sat, 8 Aug 2026 09:16:39 +0200 Subject: [PATCH 2/3] fix(cmd): canonical roots and linked-worktree semantics ResolveGlobalRoots keeps the logical recovered root for plain projects and uses the canonical physical roots only for linked worktrees. Setup and doctor canonicalize their resolved root so config, hooks, and output paths agree (macOS /var -> /private/var). Doctor accepts hooks whose executable is the running binary (test or renamed artifact) and matches the local-scope MCP project key in either the logical or canonical spelling. The malformed project-gitfile rejection test accepts both the merged gitfile-validation and projectpath messages. Co-Authored-By: Whale integration --- cmd/doctor.go | 30 +++++++++++++++++++++++++++++- cmd/root.go | 6 ++++-- cmd/root_test.go | 2 +- cmd/setup.go | 5 +++++ main.go | 10 ++++++++++ 5 files changed, 49 insertions(+), 4 deletions(-) diff --git a/cmd/doctor.go b/cmd/doctor.go index e407a66..11799d0 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -82,6 +82,11 @@ func RunDoctor(args []string, defaultRoot string) int { fmt.Fprintf(os.Stderr, "Error resolving path: %v\n", err) return 1 } + // Canonicalize so doctor paths, config, and hooks agree (e.g. macOS + // /tmp -> /private/tmp, /var -> /private/var). + if canonical, err := filepath.EvalSymlinks(root); err == nil { + root = canonical + } failures := 0 checkExecutable := func(label, name string, required bool) (bool, string) { @@ -487,7 +492,19 @@ func splitHookCommand(command string) (executable, args string, ok bool) { func hookExecutableIsCodemap(path string) bool { normalized := strings.ReplaceAll(path, `\`, "/") name := strings.ToLower(normalized[strings.LastIndex(normalized, "/")+1:]) - return name == "codemap" || name == "codemap.exe" + if name == "codemap" || name == "codemap.exe" { + return true + } + // The running binary is codemap even when it is a test binary or a + // renamed deploy artifact; accept the exact executable that wrote the hook. + if running, err := os.Executable(); err == nil { + if abs, err := filepath.Abs(path); err == nil { + if runningAbs, err := filepath.Abs(running); err == nil && abs == runningAbs { + return true + } + } + } + return false } func validateClaudeMCP(path string) error { @@ -529,6 +546,17 @@ func validateClaudeLocalMCP(projectRoot string) func(string) error { return fmt.Errorf("no project-scoped MCP servers registered") } project, ok := projects[projectRoot].(map[string]any) + if !ok { + // The stored key may use the logical or canonical spelling of the + // same directory; match the canonical form either way. + for key, value := range projects { + keyCanonical, err := filepath.EvalSymlinks(key) + if err == nil && keyCanonical == projectRoot { + project, ok = value.(map[string]any) + break + } + } + } if !ok { return fmt.Errorf("no MCP servers registered for %s", projectRoot) } diff --git a/cmd/root.go b/cmd/root.go index 76227fe..1183a26 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -117,10 +117,12 @@ func ResolveGlobalRoots(opts GlobalRootOptions, launchDir string) (InvocationRoo } runtimeRoot = setupRoot source = projectpath.SourceExplicit - } else { + } else if projectSelection.Source == projectpath.SourceLinkedWorktree { + // Linked worktrees resolve to the physical primary and worktree roots. + projectRoot = projectSelection.ProjectRoot setupRoot = projectSelection.SetupRoot runtimeRoot = projectSelection.RuntimeRoot - source = projectSelection.Source + source = projectpath.SourceLinkedWorktree } if err := validateCodemapStorageRoot(setupRoot); err != nil { return InvocationRoots{}, fmt.Errorf("resolve setup root: %w", err) diff --git a/cmd/root_test.go b/cmd/root_test.go index ad53b71..c8d9846 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -599,7 +599,7 @@ func TestResolveGlobalRoots(t *testing.T) { Directory: malformedProject, SetupRoot: setupNested, }, launchDir) - if err == nil || !strings.Contains(err.Error(), "resolve linked worktree setup") { + if err == nil || (!strings.Contains(err.Error(), "resolve linked worktree setup") && !strings.Contains(err.Error(), "invalid Git marker")) { t.Fatalf("ResolveGlobalRoots() error = %v, want malformed project gitfile rejection", err) } }) diff --git a/cmd/setup.go b/cmd/setup.go index 48b9ba2..5ef88fd 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -225,6 +225,11 @@ func RunSetup(args []string, defaultRoot string) int { fmt.Fprintf(os.Stderr, "Error resolving path: %v\n", err) return 1 } + // Canonicalize so setup output, config paths, and hooks agree (e.g. macOS + // /tmp -> /private/tmp, /var -> /private/var). + if canonical, err := filepath.EvalSymlinks(absRoot); err == nil { + absRoot = canonical + } executable, err := resolveIntegrationExecutable() if err != nil { fmt.Fprintf(os.Stderr, "Error resolving codemap executable: %v\n", err) diff --git a/main.go b/main.go index b7f4ec0..3c4d6ce 100644 --- a/main.go +++ b/main.go @@ -490,6 +490,11 @@ func applyGlobalRootOptions(args []string) ([]string, error) { if err != nil { return nil, err } + // Canonicalize the stored setup root so projectpath and daemon args agree + // with the resolved project root (e.g. macOS /var -> /private/var). + if canonical, err := filepath.EvalSymlinks(roots.Setup); err == nil { + roots.Setup = canonical + } if err := os.Chdir(roots.Project); err != nil { return nil, fmt.Errorf("change to project root %q: %w", roots.Project, err) } @@ -723,6 +728,11 @@ func runWatchSubcommand(subCmd, root string) { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } + // Canonicalize so the daemon identity and path comparisons agree (e.g. + // macOS /var -> /private/var). + if canonical, err := filepath.EvalSymlinks(absRoot); err == nil { + absRoot = canonical + } switch subCmd { case "start": From d14c8702a6feeb7f654906d35d3333701ff7943a Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Sat, 8 Aug 2026 09:21:38 +0200 Subject: [PATCH 3/3] test(projectpath): cover RuntimeRoot selection and its fallback RuntimeRoot was untested: cover the plain-project canonical root, the linked-worktree local runtime root, and the cleaned-input fallback when selection fails. Co-Authored-By: Whale integration --- internal/projectpath/path_test.go | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/internal/projectpath/path_test.go b/internal/projectpath/path_test.go index 07058f9..629e414 100644 --- a/internal/projectpath/path_test.go +++ b/internal/projectpath/path_test.go @@ -343,3 +343,32 @@ func TestPrependSetupRootArgs(t *testing.T) { } } } +func TestRuntimeRootAndCheckedRuntimeCodemapDir(t *testing.T) { + ResetSetupRoot() + t.Cleanup(ResetSetupRoot) + + root := t.TempDir() + canonicalRoot, err := filepath.EvalSymlinks(root) + if err != nil { + t.Fatal(err) + } + if got := RuntimeRoot(root); got != canonicalRoot { + t.Fatalf("RuntimeRoot() = %q, want canonical %q", got, canonicalRoot) + } + + // Linked worktrees keep runtime state local to the worktree root. + _, linked := makeLinkedWorktreeFixture(t, false) + canonicalLinked, err := filepath.EvalSymlinks(linked) + if err != nil { + t.Fatal(err) + } + if got := RuntimeRoot(linked); got != canonicalLinked { + t.Fatalf("linked RuntimeRoot() = %q, want %q", got, canonicalLinked) + } + + // The convenience form falls back to the cleaned input when selection fails. + missing := filepath.Join(t.TempDir(), "missing") + if got := RuntimeRoot(missing); got != filepath.Clean(missing) { + t.Fatalf("RuntimeRoot() fallback = %q, want %q", got, filepath.Clean(missing)) + } +}