diff --git a/cmd/config.go b/cmd/config.go index 564e2ff..114c87e 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -69,6 +69,7 @@ func configInit(root string) { } fmt.Printf("Created %s\n", result.Path) + reportEnsureIgnored(os.Stdout, root, ignoreTracked) fmt.Println() if len(result.TopExts) == 0 { fmt.Println("No code extensions detected — wrote empty config.") diff --git a/cmd/gitignore.go b/cmd/gitignore.go new file mode 100644 index 0000000..86d81eb --- /dev/null +++ b/cmd/gitignore.go @@ -0,0 +1,163 @@ +package cmd + +import ( + "bufio" + "bytes" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// ignoreMode selects where ensureCodemapIgnored writes a rule when one is +// needed. +type ignoreMode int + +const ( + // ignoreTracked writes ".codemap/" to the repo's tracked .gitignore so the + // whole team (and every future clone) ignores it. This creates a diff. + ignoreTracked ignoreMode = iota + // ignoreLocal writes ".codemap/" to .git/info/exclude, which is local to + // this clone and never tracked — no diff, no surprise for collaborators. + ignoreLocal +) + +// ignoreEntry is the rule we add. The trailing slash scopes it to the +// directory, matching how codemap's own repo ignores its runtime state. +const ignoreEntry = ".codemap/" + +// reportEnsureIgnored ensures .codemap/ is ignored for root and reports the +// outcome to w: a one-line notice when a rule was added, a warning when the +// check/write failed, and nothing when git already ignored it (or root is not +// a git repo). Both "config init" and "setup" funnel through here. +func reportEnsureIgnored(w io.Writer, root string, mode ignoreMode) { + wrote, err := ensureCodemapIgnored(root, mode) + if err != nil { + fmt.Fprintf(w, "Warning: could not update ignore rules: %v\n", err) + return + } + if wrote == "" { + return + } + rel := wrote + if r, relErr := filepath.Rel(root, wrote); relErr == nil { + rel = r + } + fmt.Fprintf(w, "Added %s to %s\n", ignoreEntry, rel) +} + +// ensureCodemapIgnored makes sure the .codemap/ directory at the given git root +// is ignored by git. It is a no-op when git already ignores .codemap through +// any mechanism (a global exclude, an existing .gitignore, a parent rule), and +// a silent no-op when root is not inside a git working tree. +// +// It returns the path of the file it wrote to, or "" when nothing was written. +func ensureCodemapIgnored(root string, mode ignoreMode) (string, error) { + if !isGitWorkTree(root) { + return "", nil + } + + ignored, err := gitCheckIgnore(root, ignoreEntry) + if err != nil { + return "", err + } + if ignored { + return "", nil + } + + var target string + if mode == ignoreLocal { + target, err = infoExcludePath(root) + if err != nil { + return "", err + } + } else { + target = filepath.Join(root, ".gitignore") + } + + if err := appendIgnoreEntry(target, ignoreEntry); err != nil { + return "", err + } + return target, nil +} + +// 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" +} + +// gitCheckIgnore reports whether git would ignore the given path at root. +func gitCheckIgnore(root, path string) (bool, error) { + cmd := exec.Command("git", "check-ignore", "-q", path) + cmd.Dir = root + err := cmd.Run() + if err == nil { + return true, 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) +} + +// infoExcludePath resolves /info/exclude for root, so all +// worktrees of a repo share one local exclude file. +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 +} + +// appendIgnoreEntry adds entry as its own line in the ignore file at path, +// creating the file (and its parent dir) if needed. It leaves the file +// untouched if entry is already present verbatim, and guarantees the entry +// lands on a fresh line even when the file lacks a trailing newline. +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 os.WriteFile(path, buf.Bytes(), 0644) +} + +// hasIgnoreLine reports whether data already contains entry as a standalone, +// non-comment line. +func hasIgnoreLine(data []byte, entry string) bool { + scanner := bufio.NewScanner(bytes.NewReader(data)) + for scanner.Scan() { + if strings.TrimSpace(scanner.Text()) == entry { + return true + } + } + return false +} diff --git a/cmd/gitignore_test.go b/cmd/gitignore_test.go new file mode 100644 index 0000000..9107b0f --- /dev/null +++ b/cmd/gitignore_test.go @@ -0,0 +1,199 @@ +package cmd + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func gitIgnoresCodemap(t *testing.T, root string) bool { + t.Helper() + // Trailing slash so the directory-scoped rule matches even though + // .codemap does not exist on disk during the test. + cmd := exec.Command("git", "check-ignore", "-q", ".codemap/") + cmd.Dir = root + err := cmd.Run() + if err == nil { + return true + } + if exit, ok := err.(*exec.ExitError); ok && exit.ExitCode() == 1 { + return false + } + t.Fatalf("git check-ignore failed unexpectedly: %v", err) + return false +} + +func makeGitRepo(t *testing.T) string { + t.Helper() + root := t.TempDir() + runGitTestCmd(t, root, "init") + return root +} + +func TestEnsureCodemapIgnored(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + + t.Run("writes to tracked gitignore when not ignored", func(t *testing.T) { + root := makeGitRepo(t) + + path, err := ensureCodemapIgnored(root, ignoreTracked) + if err != nil { + t.Fatalf("ensureCodemapIgnored: %v", err) + } + want := filepath.Join(root, ".gitignore") + if path != want { + t.Fatalf("path = %q, want %q", path, want) + } + data, err := os.ReadFile(want) + if err != nil { + t.Fatalf("read .gitignore: %v", err) + } + if !strings.Contains(string(data), ".codemap/") { + t.Fatalf(".gitignore missing .codemap/ entry:\n%s", data) + } + if !gitIgnoresCodemap(t, root) { + t.Fatal("git still does not ignore .codemap after write") + } + }) + + t.Run("no-op when already ignored", func(t *testing.T) { + root := makeGitRepo(t) + gi := filepath.Join(root, ".gitignore") + if err := os.WriteFile(gi, []byte("node_modules/\n.codemap/\n"), 0644); err != nil { + t.Fatal(err) + } + before, _ := os.ReadFile(gi) + + path, err := ensureCodemapIgnored(root, ignoreTracked) + if err != nil { + t.Fatalf("ensureCodemapIgnored: %v", err) + } + if path != "" { + t.Fatalf("path = %q, want empty (nothing written)", path) + } + after, _ := os.ReadFile(gi) + if string(before) != string(after) { + t.Fatalf(".gitignore was modified:\nbefore:\n%s\nafter:\n%s", before, after) + } + }) + + t.Run("creates gitignore when missing", func(t *testing.T) { + root := makeGitRepo(t) + if _, err := ensureCodemapIgnored(root, ignoreTracked); err != nil { + t.Fatalf("ensureCodemapIgnored: %v", err) + } + if _, err := os.Stat(filepath.Join(root, ".gitignore")); err != nil { + t.Fatalf(".gitignore not created: %v", err) + } + }) + + t.Run("appends preserving existing content and trailing newline", func(t *testing.T) { + root := makeGitRepo(t) + gi := filepath.Join(root, ".gitignore") + // No trailing newline on purpose. + if err := os.WriteFile(gi, []byte("build/\n*.log"), 0644); err != nil { + t.Fatal(err) + } + if _, err := ensureCodemapIgnored(root, ignoreTracked); err != nil { + t.Fatalf("ensureCodemapIgnored: %v", err) + } + data, _ := os.ReadFile(gi) + got := string(data) + if !strings.Contains(got, "build/") || !strings.Contains(got, "*.log") { + t.Fatalf("existing content lost:\n%s", got) + } + if !strings.Contains(got, "\n.codemap/\n") { + t.Fatalf(".codemap/ not on its own line:\n%q", got) + } + }) + + t.Run("ignoreLocal writes to info/exclude and not gitignore", func(t *testing.T) { + root := makeGitRepo(t) + + path, err := ensureCodemapIgnored(root, ignoreLocal) + if err != nil { + t.Fatalf("ensureCodemapIgnored: %v", err) + } + if !strings.HasSuffix(path, filepath.Join("info", "exclude")) { + t.Fatalf("path = %q, want .git/info/exclude", path) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read exclude: %v", err) + } + if !strings.Contains(string(data), ".codemap/") { + t.Fatalf("info/exclude missing .codemap/:\n%s", data) + } + if _, err := os.Stat(filepath.Join(root, ".gitignore")); !os.IsNotExist(err) { + t.Fatal("ignoreLocal must not create a tracked .gitignore") + } + if !gitIgnoresCodemap(t, root) { + t.Fatal("git does not ignore .codemap after info/exclude write") + } + }) + + t.Run("skips silently when not a git repo", func(t *testing.T) { + root := t.TempDir() + path, err := ensureCodemapIgnored(root, ignoreTracked) + if err != nil { + t.Fatalf("ensureCodemapIgnored on non-git dir: %v", err) + } + if path != "" { + t.Fatalf("path = %q, want empty for non-git dir", path) + } + if _, err := os.Stat(filepath.Join(root, ".gitignore")); !os.IsNotExist(err) { + t.Fatal("must not create .gitignore outside a git repo") + } + }) +} + +func TestReportEnsureIgnored(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + + t.Run("prints notice and writes rule on first run", func(t *testing.T) { + root := makeGitRepo(t) + var buf bytes.Buffer + + reportEnsureIgnored(&buf, root, ignoreTracked) + + out := buf.String() + if !strings.Contains(out, "Added .codemap/ to .gitignore") { + t.Fatalf("notice missing or malformed:\n%s", out) + } + if !gitIgnoresCodemap(t, root) { + t.Fatal("git does not ignore .codemap after reportEnsureIgnored") + } + }) + + t.Run("prints nothing when already ignored", func(t *testing.T) { + root := makeGitRepo(t) + if err := os.WriteFile(filepath.Join(root, ".gitignore"), []byte(".codemap/\n"), 0644); err != nil { + t.Fatal(err) + } + var buf bytes.Buffer + + reportEnsureIgnored(&buf, root, ignoreTracked) + + if buf.Len() != 0 { + t.Fatalf("expected no output, got:\n%s", buf.String()) + } + }) + + t.Run("prints nothing outside a git repo", func(t *testing.T) { + root := t.TempDir() + var buf bytes.Buffer + + reportEnsureIgnored(&buf, root, ignoreTracked) + + if buf.Len() != 0 { + t.Fatalf("expected no output for non-git dir, got:\n%s", buf.String()) + } + }) +} diff --git a/cmd/setup.go b/cmd/setup.go index f82686c..2d62464 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -114,6 +114,8 @@ func RunSetup(args []string, defaultRoot string) { } } + reportEnsureIgnored(os.Stdout, absRoot, ignoreTracked) + if *skipHooks { fmt.Println("Hooks: skipped (--no-hooks)") } else {