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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,7 @@ codemap config show # display current config
}
```

All fields are optional; CLI flags always override config. When an MCP file search finds real matches hidden by `only`, codemap reports the paths and suggests which extensions to add — set `guidance.missing_extension_hints: false` to disable.
All fields are optional. `--only` and `--depth` override the config's values for one run; `--exclude` adds to the config's `exclude` list, so a config exclude keeps applying when you pass more patterns on the command line. When an MCP file search finds real matches hidden by `only`, codemap reports the paths and suggests which extensions to add — set `guidance.missing_extension_hints: false` to disable.

## Skills

Expand Down
4 changes: 3 additions & 1 deletion cmd/agent_edits.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (
"path/filepath"
"strings"
"time"

"codemap/internal/gitexclude"
)

// The watch daemon sees every write on disk — including git merges, checkouts,
Expand Down Expand Up @@ -54,7 +56,7 @@ func recordAgentEdit(root, sessionID, path string, now time.Time) error {
if strings.TrimSpace(path) == "" {
return nil
}
if err := os.MkdirAll(filepath.Join(root, ".codemap"), 0o755); err != nil {
if err := gitexclude.MkdirAll(filepath.Join(root, ".codemap"), 0o755); err != nil {
return err
}
record := agentEditRecord{
Expand Down
12 changes: 11 additions & 1 deletion cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"strings"

"codemap/config"
"codemap/internal/gitexclude"
"codemap/scanner"
)

Expand Down Expand Up @@ -76,13 +77,22 @@ func EnsureProjectConfig(root string) bool {
if _, err := os.Stat(filepath.Join(root, ".git")); err != nil {
return false
}
if _, err := initProjectConfig(root); err != nil {
result, err := initProjectConfig(root)
if err != nil {
return false
}
// The auto-write just created .codemap/ in a checkout that never asked
// for it, so keep it out of `git status` (issue #184): the rule goes to
// the clone-local info/exclude, never a tracked .gitignore, and only
// when no existing rule already ignores the directory.
excludeFile, _ := gitexclude.EnsureIgnoredIfNeeded(filepath.Dir(filepath.Dir(result.Path)))
// Only a person at a terminal needs the notice; piped consumers that
// merge stderr into a JSON stream must not see it.
if info, err := os.Stderr.Stat(); err == nil && info.Mode()&os.ModeCharDevice != 0 {
fmt.Fprintln(os.Stderr, "codemap: wrote .codemap/config.json (auto-detected); tune with: codemap skill show config-setup")
if excludeFile != "" {
fmt.Fprintf(os.Stderr, "codemap: added %s to %s so git status stays clean; verify with: git check-ignore -v .codemap\n", gitexclude.Entry, excludeFile)
}
}
return true
}
Expand Down
37 changes: 37 additions & 0 deletions cmd/config_more_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
Expand Down Expand Up @@ -224,3 +225,39 @@ func TestEnsureProjectConfigWritesOnlyInsideRepos(t *testing.T) {
t.Fatalf("config unexpectedly present in non-repo: %v", err)
}
}

// TestEnsureProjectConfigKeepsFreshCloneClean is the exit test from issue
// #184: a fresh clone of a repo with no codemap config, one codemap command,
// and `git status --porcelain` is empty. The auto-written config lands in
// .codemap/, which the auto-init must ignore via the clone-local
// info/exclude, never by creating or editing a tracked .gitignore.
func TestEnsureProjectConfigKeepsFreshCloneClean(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
src := makeRepoOnBranch(t, "main")
clone := filepath.Join(t.TempDir(), "clone")
runGitTestCmd(t, src, "clone", "-q", src, clone)

if !EnsureProjectConfig(clone) {
t.Fatal("expected config to be written in a fresh clone")
}
if _, err := os.Stat(config.ConfigPath(clone)); err != nil {
t.Fatalf("config not written: %v", err)
}
status := exec.Command("git", "status", "--porcelain")
status.Dir = clone
out, err := status.Output()
if err != nil {
t.Fatal(err)
}
if strings.TrimSpace(string(out)) != "" {
t.Fatalf("git status --porcelain not empty after auto-init:\n%s", out)
}
if _, err := os.Stat(filepath.Join(clone, ".gitignore")); !os.IsNotExist(err) {
t.Fatal("auto-init must not create a tracked .gitignore")
}
if !gitIgnoresCodemap(t, clone) {
t.Fatal("git does not ignore .codemap after auto-init")
}
}
20 changes: 20 additions & 0 deletions cmd/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (

"codemap/config"
"codemap/internal/buildinfo"
"codemap/internal/gitexclude"

"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/pelletier/go-toml/v2"
Expand Down Expand Up @@ -114,6 +115,7 @@ func RunDoctor(args []string, defaultRoot string) int {
checkFile("project config", config.ConfigPath(root), validateJSONFile)
if isGitWorkTree(root) {
doctorCheckLocalCodemapIgnore(root, &failures)
doctorCheckTrackedCodemapState(root, &failures)
}
claudeSettings, claudeSettingsErr := claudeSettingsPath(root, *global)
claudeMCP, claudeMCPErr := claudeMCPPath(root, *global)
Expand Down Expand Up @@ -202,6 +204,24 @@ func doctorCheckLocalCodemapIgnore(root string, failures *int) {
*failures++
}

// doctorCheckTrackedCodemapState reports .codemap/ files git already tracks
// (`git ls-files .codemap`): an ignore rule cannot hide those, so they show
// up in every diff until they are removed from the index.
func doctorCheckTrackedCodemapState(root string, failures *int) {
tracked, err := gitexclude.TrackedStateFiles(root)
if err != nil {
fmt.Printf("MISS tracked codemap state: %v\n", err)
*failures++
return
}
if len(tracked) > 0 {
fmt.Printf("MISS tracked codemap state: %d file(s) under .codemap/ are committed; run: git rm -r --cached .codemap\n", len(tracked))
*failures++
return
}
fmt.Println("OK tracked codemap state: 0 files under .codemap/ are committed (git ls-files .codemap)")
}

func reportCodexRuntimeVersions(cliPath string) bool {
var desktopPaths []string
switch doctorRuntimeGOOS {
Expand Down
108 changes: 13 additions & 95 deletions cmd/gitignore.go
Original file line number Diff line number Diff line change
@@ -1,18 +1,17 @@
package cmd

import (
"bytes"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"

"codemap/internal/gitexclude"
)

// ignoreEntry keeps the runtime-state directory out of git. The trailing
// slash scopes the rule to the directory.
const ignoreEntry = ".codemap/"
const ignoreEntry = gitexclude.Entry

// reportEnsureIgnored ensures .codemap/ is ignored for root and reports the
// outcome to w (notice, warning, or nothing).
Expand All @@ -36,119 +35,38 @@ func reportEnsureIgnored(w io.Writer, root string) {
// ignoreEntry to the clone-local info/exclude. It returns the file written,
// or "" when the entry already exists or root is not a git work tree.
func ensureCodemapIgnored(root string) (string, error) {
if !isGitWorkTree(root) {
return "", nil
}
target, present, err := localCodemapIgnore(root)
if err != nil {
return "", err
}
if present {
return "", nil
}
if err := appendIgnoreEntry(target, ignoreEntry); err != nil {
return "", err
}
return target, nil
return gitexclude.EnsureIgnored(root)
}

// localCodemapIgnore reports whether root's info/exclude already lists
// ignoreEntry.
func localCodemapIgnore(root string) (string, bool, error) {
target, err := infoExcludePath(root)
if err != nil {
return "", false, err
}
data, err := os.ReadFile(target)
if os.IsNotExist(err) {
return target, false, nil
}
if err != nil {
return "", false, err
}
return target, hasIgnoreLine(data, ignoreEntry), nil
return gitexclude.LocalEntry(root)
}

// isGitWorkTree reports whether root sits inside a git working tree.
func isGitWorkTree(root string) bool {
cmd := exec.Command("git", "rev-parse", "--is-inside-work-tree")
cmd.Dir = root
out, err := cmd.Output()
return err == nil && strings.TrimSpace(string(out)) == "true"
return gitexclude.IsWorkTree(root)
}

// gitCheckIgnoreVerbose reports whether git would ignore path at root and, if
// so, the <source>:<line>:<pattern> rule that matched, across every ignore
// source (tracked .gitignore, local exclude, global excludes, parent repos).
// so, the <source>:<line>:<pattern> rule that matched.
func gitCheckIgnoreVerbose(root, path string) (bool, string, error) {
cmd := exec.Command("git", "check-ignore", "-v", path)
cmd.Dir = root
out, err := cmd.Output()
if err == nil {
line := strings.TrimSpace(string(out))
if i := strings.IndexByte(line, '\t'); i >= 0 {
line = line[:i]
}
return true, line, nil
}
// Exit code 1 means "not ignored"; anything else is a real error.
if exit, ok := err.(*exec.ExitError); ok && exit.ExitCode() == 1 {
return false, "", nil
}
return false, "", fmt.Errorf("git check-ignore: %w", err)
return gitexclude.CheckIgnored(root, path)
}

// infoExcludePath resolves <git-common-dir>/info/exclude for root, so every
// worktree of a repo shares one local exclude file.
// infoExcludePath resolves <git-common-dir>/info/exclude for root.
func infoExcludePath(root string) (string, error) {
cmd := exec.Command("git", "rev-parse", "--git-common-dir")
cmd.Dir = root
out, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("git rev-parse --git-common-dir: %w", err)
}
commonDir := strings.TrimSpace(string(out))
if !filepath.IsAbs(commonDir) {
commonDir = filepath.Join(root, commonDir)
}
return filepath.Join(commonDir, "info", "exclude"), nil
return gitexclude.InfoExcludePath(root)
}

// appendIgnoreEntry adds entry on its own line to the ignore file at path,
// creating the file if needed. No-op when entry is already listed; never
// glues onto a partial last line. Atomic write, so a symlinked exclude is
// replaced rather than written through.
// appendIgnoreEntry adds entry on its own line to the ignore file at path.
func appendIgnoreEntry(path, entry string) error {
existing, err := os.ReadFile(path)
if err != nil && !os.IsNotExist(err) {
return err
}
if hasIgnoreLine(existing, entry) {
return nil
}

if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}

var buf bytes.Buffer
buf.Write(existing)
if len(existing) > 0 && !bytes.HasSuffix(existing, []byte("\n")) {
buf.WriteByte('\n')
}
buf.WriteString(entry)
buf.WriteByte('\n')

return writeFileAtomic(path, buf.Bytes(), 0644)
return gitexclude.AppendEntry(path, entry)
}

// hasIgnoreLine reports whether data lists entry as a standalone, uncommented
// line.
func hasIgnoreLine(data []byte, entry string) bool {
for _, line := range bytes.Split(data, []byte("\n")) {
if strings.TrimSpace(string(line)) == entry {
return true
}
}
return false
return gitexclude.HasLine(data, entry)
}
5 changes: 3 additions & 2 deletions cmd/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (

"codemap/config"
"codemap/handoff"
"codemap/internal/gitexclude"
"codemap/internal/projectpath"
"codemap/limits"
"codemap/scanner"
Expand Down Expand Up @@ -1475,7 +1476,7 @@ func hookPreCompact(root string) error {
if err != nil {
return err
}
if err := os.MkdirAll(codemapDir, 0755); err != nil {
if err := gitexclude.MkdirAll(codemapDir, 0755); err != nil {
return err
}

Expand Down Expand Up @@ -1869,7 +1870,7 @@ func updateSessionLeaseContext(ctx context.Context, root, sessionID string, acti
if err != nil {
return err
}
if err := os.MkdirAll(codemapDir, 0o755); err != nil {
if err := gitexclude.MkdirAll(codemapDir, 0o755); err != nil {
return err
}
lockPath := filepath.Join(codemapDir, "sessions.lock")
Expand Down
37 changes: 37 additions & 0 deletions cmd/setup_review_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -597,3 +597,40 @@ func TestDoctorCheckLocalCodemapIgnoreFailsWhenGitUnavailable(t *testing.T) {
t.Fatalf("expected MISS local-ignore diagnosis, got:\n%s", out)
}
}

// TestRunDoctorReportsTrackedCodemapState locks in the doctor line for
// .codemap/ files that are already committed: an ignore rule cannot hide
// them, so doctor names the count and the git command that removes them
// from the index (issue #184).
func TestRunDoctorReportsTrackedCodemapState(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
root := makeRepoOnBranch(t, "main")
if _, err := ensureCodemapIgnored(root); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(root, ".codemap"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, ".codemap", "config.json"), []byte("{}\n"), 0o644); err != nil {
t.Fatal(err)
}

out := captureOutput(func() { RunDoctor([]string{root}, ".") })
if !strings.Contains(out, "OK tracked codemap state: 0 files under .codemap/ are committed") {
t.Fatalf("expected clean tracked-state line, got:\n%s", out)
}

runGitTestCmd(t, root, "add", "-f", ".codemap")
runGitTestCmd(t, root, "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-q", "-m", "track codemap state")

var code int
out = captureOutput(func() { code = RunDoctor([]string{root}, ".") })
if code == 0 {
t.Fatalf("RunDoctor exit code = 0, want tracked state to fail\n%s", out)
}
if !strings.Contains(out, "MISS tracked codemap state: 1 file(s) under .codemap/ are committed; run: git rm -r --cached .codemap") {
t.Fatalf("expected tracked-state diagnosis, got:\n%s", out)
}
}
3 changes: 2 additions & 1 deletion cmd/skill.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"path/filepath"
"strings"

"codemap/internal/gitexclude"
"codemap/internal/projectpath"
"codemap/skills"
)
Expand Down Expand Up @@ -99,7 +100,7 @@ func runSkillShow(root, name string) {

func runSkillInit(root string) {
skillsDir := filepath.Join(projectpath.CodemapDir(root), "skills")
if err := os.MkdirAll(skillsDir, 0755); err != nil {
if err := gitexclude.MkdirAll(skillsDir, 0755); err != nil {
fmt.Fprintf(os.Stderr, "Error creating skills directory: %v\n", err)
os.Exit(1)
}
Expand Down
Loading
Loading