// Package gitignored refuses an ignore pattern that hides a module's source (catalogue review F1, and its kind): the // bare pattern `mesh-build-node` in a module's .gitignore, meant for the binary `go build` leaves, also matched the // directory cmd/mesh-build-node, so git ignored every new file there and publish.go was never committed. A path under // a module's cmd/ or internal/ is source: no .gitignore, the module's or the catalogue's, may ignore one, tracked or // new. A binary is ignored by an anchored pattern (`/mesh-build-node`, `/cmd/mesh-build-node/mesh-build-node`). package gitignored import ( "bytes" "fmt" "io/fs" "os" "os/exec" "path/filepath" "sort" "strings" ) // Probe is the file name asked of each source directory: a Go file a change might add there. const Probe = "added_by_a_change.go" // Hidden is one source path an ignore pattern hides, and the pattern (its file, line and text, as git names it). type Hidden struct { Path, Pattern string } // Sources are the source directories of root's modules, relative to root: every directory under modules/*/cmd and // modules/*/internal (node_modules left out). func Sources(root string) ([]string, error) { mods, err := filepath.Glob(filepath.Join(root, "modules", "*")) if err != nil { return nil, err } var out []string for _, m := range mods { for _, top := range []string{"cmd", "internal"} { base := filepath.Join(m, top) if st, err := os.Stat(base); err != nil || !st.IsDir() { continue } err := filepath.WalkDir(base, func(p string, d fs.DirEntry, err error) error { if err != nil { return err } if !d.IsDir() { return nil } if d.Name() == "node_modules" { return filepath.SkipDir } rel, _ := filepath.Rel(root, p) out = append(out, filepath.ToSlash(rel)) return nil }) if err != nil { return nil, err } } } sort.Strings(out) return out, nil } // isSource is whether a file is source a module's build reads: Go and TypeScript files and a Go module's own files. // What a build or a test script leaves beside them (a binary, compiled JavaScript) may be ignored. func isSource(name string) bool { switch filepath.Ext(name) { case ".go", ".ts", ".tsx": return true } return name == "go.mod" || name == "go.sum" } // Find asks git which of the source paths root's ignore files hide: each source directory, a Go file added in it, and // every source file there (isSource). It reads the .gitignore files of root's tree through a repository of its own, so it judges a git // checkout and a plain folder alike, and no one's global ignores count. func Find(root string, dirs []string) ([]Hidden, error) { gitDir, err := os.MkdirTemp("", "gitignored-") if err != nil { return nil, err } defer os.RemoveAll(gitDir) if out, err := exec.Command("git", "init", "-q", "--bare", gitDir).CombinedOutput(); err != nil { return nil, fmt.Errorf("git init: %v: %s", err, out) } var ask bytes.Buffer for _, d := range dirs { ask.WriteString(d + "/\x00" + d + "/" + Probe + "\x00") entries, err := os.ReadDir(filepath.Join(root, d)) if err != nil { return nil, err } for _, e := range entries { if !e.IsDir() && isSource(e.Name()) { ask.WriteString(d + "/" + e.Name() + "\x00") } } } abs, err := filepath.Abs(root) if err != nil { return nil, err } cmd := exec.Command("git", "--git-dir="+gitDir, "--work-tree="+abs, "-c", "core.excludesFile=/dev/null", "check-ignore", "--no-index", "-v", "-z", "--stdin") cmd.Dir = abs cmd.Stdin = &ask out, err := cmd.Output() if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 { return nil, nil // nothing is ignored } if err != nil { return nil, fmt.Errorf("git check-ignore: %w", err) } // -v -z answers source, line, pattern and path, each ended by NUL. f := strings.Split(strings.TrimRight(string(out), "\x00"), "\x00") var hidden []Hidden for i := 0; i+3 < len(f); i += 4 { if f[i+2] == "" || strings.HasPrefix(f[i+2], "!") { continue // a negation keeps the path } hidden = append(hidden, Hidden{Path: f[i+3], Pattern: fmt.Sprintf("%s:%s: %s", f[i], f[i+1], f[i+2])}) } return hidden, nil }