Skip to content
Open
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
1 change: 1 addition & 0 deletions cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand Down
163 changes: 163 additions & 0 deletions cmd/gitignore.go
Original file line number Diff line number Diff line change
@@ -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 <git-common-dir>/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
}
199 changes: 199 additions & 0 deletions cmd/gitignore_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
})
}
2 changes: 2 additions & 0 deletions cmd/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ func RunSetup(args []string, defaultRoot string) {
}
}

reportEnsureIgnored(os.Stdout, absRoot, ignoreTracked)

if *skipHooks {
fmt.Println("Hooks: skipped (--no-hooks)")
} else {
Expand Down
Loading