diff --git a/README.md b/README.md index 2b80f5c..6e80578 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 c21dd61..3dc7ebd 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 2736b38..31970e3 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..11799d0 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,11 +72,21 @@ 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 + } + // 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) { @@ -100,7 +111,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) @@ -481,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 { @@ -523,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) } @@ -605,14 +639,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 42caabc..ca7d66a 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..1183a26 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,20 @@ 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 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 = projectpath.SourceLinkedWorktree } 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 +146,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..c8d9846 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") && !strings.Contains(err.Error(), "invalid Git marker")) { + 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 388bd96..21e6368 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -8,7 +8,6 @@ import ( "fmt" "net/http" "os" - "path/filepath" "strings" "time" @@ -31,7 +30,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 8259022..28cd1e8 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" ) @@ -100,7 +101,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"}, @@ -112,7 +113,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"}, @@ -151,6 +152,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: @@ -207,11 +216,21 @@ 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 + } + // 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) @@ -219,7 +238,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) } } @@ -266,7 +285,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 { @@ -281,7 +300,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 } @@ -403,7 +422,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 { @@ -437,14 +456,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 @@ -456,7 +475,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) @@ -477,7 +496,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 } @@ -489,7 +508,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 @@ -523,7 +542,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 1a4f79b..8f4dfd3 100644 --- a/cmd/setup_test.go +++ b/cmd/setup_test.go @@ -4,10 +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/config/config.go b/config/config.go index 48604ec..d3e6662 100644 --- a/config/config.go +++ b/config/config.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strings" + "codemap/internal/projectpath" "codemap/limits" ) @@ -226,7 +227,7 @@ func (c ProjectConfig) RoutingTopKOrDefault() int { // ConfigPath returns the path to .codemap/config.json for the given root. func ConfigPath(root string) string { - return filepath.Join(root, ".codemap", "config.json") + return filepath.Join(projectpath.CodemapDir(root), "config.json") } // Load reads .codemap/config.json from root. diff --git a/config/config_test.go b/config/config_test.go index 48f4c77..b3a9bd7 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -6,6 +6,7 @@ import ( "strings" "testing" + "codemap/internal/projectpath" "codemap/limits" ) @@ -164,6 +165,67 @@ func TestConfigPath(t *testing.T) { } } +func TestConfigPathAndLoadUseSelectedSetupRoot(t *testing.T) { + projectpath.ResetSetupRoot() + t.Cleanup(projectpath.ResetSetupRoot) + + primary, linked := makeLinkedConfigWorktreeFixture(t) + primaryConfigDir := filepath.Join(primary, ".codemap") + if err := os.WriteFile(filepath.Join(primaryConfigDir, "config.json"), []byte(`{"only":["primary-only"]}`), 0644); err != nil { + t.Fatal(err) + } + + if got, want := ConfigPath(linked), filepath.Join(primaryConfigDir, "config.json"); got != want { + t.Fatalf("linked ConfigPath() = %q, want %q", got, want) + } + if got := Load(linked); len(got.Only) != 1 || got.Only[0] != "primary-only" { + t.Fatalf("linked Load() = %+v, want primary config", got) + } + + explicit := t.TempDir() + if err := os.MkdirAll(filepath.Join(explicit, ".codemap"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(explicit, ".codemap", "config.json"), []byte(`{"only":["explicit-only"]}`), 0644); err != nil { + t.Fatal(err) + } + projectpath.SetSetupRoot(explicit) + if got, want := ConfigPath(linked), filepath.Join(explicit, ".codemap", "config.json"); got != want { + t.Fatalf("explicit ConfigPath() = %q, want %q", got, want) + } + if got := Load(linked); len(got.Only) != 1 || got.Only[0] != "explicit-only" { + t.Fatalf("explicit Load() = %+v, want explicit config", got) + } +} + +func makeLinkedConfigWorktreeFixture(t *testing.T) (primary, linked string) { + t.Helper() + primary = t.TempDir() + gitDir := filepath.Join(primary, ".git", "worktrees", "agent") + if err := os.MkdirAll(gitDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(primary, ".codemap"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(gitDir, "commondir"), []byte("../..\n"), 0644); err != nil { + t.Fatal(err) + } + linked = t.TempDir() + if err := os.WriteFile(filepath.Join(linked, ".git"), []byte("gitdir: "+gitDir+"\n"), 0644); err != nil { + t.Fatal(err) + } + primary, err := filepath.EvalSymlinks(primary) + if err != nil { + t.Fatal(err) + } + linked, err = filepath.EvalSymlinks(linked) + if err != nil { + t.Fatal(err) + } + return primary, linked +} + func TestPolicyDefaultsAndClamps(t *testing.T) { t.Run("defaults for empty config", func(t *testing.T) { var cfg ProjectConfig diff --git a/docs/HOOKS.md b/docs/HOOKS.md index 364f251..a74c347 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..629e414 --- /dev/null +++ b/internal/projectpath/path_test.go @@ -0,0 +1,374 @@ +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) + } + } +} +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)) + } +} diff --git a/main.go b/main.go index 37ebfad..3c4d6ce 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,47 @@ 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 + } + // 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) + } + 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,11 +718,21 @@ 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) } + // 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": @@ -677,7 +746,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 +829,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 { diff --git a/skills/loader.go b/skills/loader.go index be7babe..e869bc1 100644 --- a/skills/loader.go +++ b/skills/loader.go @@ -6,6 +6,7 @@ import ( "sort" "strings" + "codemap/internal/projectpath" "gopkg.in/yaml.v3" ) @@ -31,7 +32,7 @@ func LoadSkills(root string) (*SkillIndex, error) { all = append(all, builtins...) // 2. Project-local skills - projectDir := filepath.Join(root, ".codemap", "skills") + projectDir := filepath.Join(projectpath.CodemapDir(root), "skills") projectSkills, _ := loadSkillsFromDir(projectDir, projectSource) all = append(all, projectSkills...) diff --git a/skills/loader_test.go b/skills/loader_test.go index 99841a0..59e5c0f 100644 --- a/skills/loader_test.go +++ b/skills/loader_test.go @@ -5,6 +5,8 @@ import ( "path/filepath" "strings" "testing" + + "codemap/internal/projectpath" ) func TestParseFrontmatter(t *testing.T) { @@ -332,3 +334,58 @@ My custom instructions.` t.Errorf("expected priority 20 from override, got %d", s.Meta.Priority) } } + +func TestLoadSkillsUsesSelectedSetupRoot(t *testing.T) { + projectpath.ResetSetupRoot() + t.Cleanup(projectpath.ResetSetupRoot) + + primary, linked := makeLinkedSkillsWorktreeFixture(t) + primarySkillsDir := filepath.Join(primary, ".codemap", "skills") + if err := os.WriteFile(filepath.Join(primarySkillsDir, "linked-primary.md"), []byte("---\nname: linked-primary\n---\n\nPrimary linked skill."), 0644); err != nil { + t.Fatal(err) + } + idx, err := LoadSkills(linked) + if err != nil { + t.Fatalf("linked LoadSkills() error: %v", err) + } + if skill := idx.ByName["linked-primary"]; skill == nil || skill.Source != projectSource || !strings.Contains(skill.Body, "Primary linked skill") { + t.Fatalf("linked LoadSkills() did not load primary skill: %#v", skill) + } + + explicit := t.TempDir() + explicitSkillsDir := filepath.Join(explicit, ".codemap", "skills") + if err := os.MkdirAll(explicitSkillsDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(explicitSkillsDir, "explicit-only.md"), []byte("---\nname: explicit-only\n---\n\nExplicit setup skill."), 0644); err != nil { + t.Fatal(err) + } + projectpath.SetSetupRoot(explicit) + idx, err = LoadSkills(linked) + if err != nil { + t.Fatalf("explicit LoadSkills() error: %v", err) + } + if skill := idx.ByName["explicit-only"]; skill == nil || skill.Source != projectSource || !strings.Contains(skill.Body, "Explicit setup skill") { + t.Fatalf("explicit LoadSkills() did not load explicit skill: %#v", skill) + } +} + +func makeLinkedSkillsWorktreeFixture(t *testing.T) (primary, linked string) { + t.Helper() + primary = t.TempDir() + gitDir := filepath.Join(primary, ".git", "worktrees", "agent") + if err := os.MkdirAll(filepath.Join(primary, ".codemap", "skills"), 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(gitDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(gitDir, "commondir"), []byte("../..\n"), 0644); err != nil { + t.Fatal(err) + } + linked = t.TempDir() + if err := os.WriteFile(filepath.Join(linked, ".git"), []byte("gitdir: "+gitDir+"\n"), 0644); err != nil { + t.Fatal(err) + } + return primary, linked +}