Files
mesh-catalog/checks/gitignored/gitignored.go
T
jschoubben 4d3c901b99 Ignore every program a local go build leaves, and refuse a main package whose program is not ignored (issue 1577)
Twice in the registry milestone a module's program, built in its folder, was committed with git add -A
and stayed in the branch's history. The ignore list was kept by hand and covered 56 of 229 programs.
checks/gitignored now lists every main package's program (in its directory and at its Go module's
root) and fails on one no ignore file names; the root .gitignore names each, anchored to its path.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 19:39:33 +01:00

293 lines
9.1 KiB
Go

// 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
}
// Programs are the paths `go build` writes a compiled program to, for every main package of every Go module under
// root (issue 1577, met twice in one milestone: a module's program committed with `git add -A`): `go build` in the
// package's directory writes it there, and `go build ./<dir>` from the module's root writes it at the root, each named
// after the package's import path. Relative to root, sorted; directories named node_modules, testdata or vendor, and
// .git, are left out, and so is a path that is a directory of the tree.
func Programs(root string) ([]string, error) {
var mods []string
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() && skipped(d.Name()) && p != root {
return filepath.SkipDir
}
if !d.IsDir() && d.Name() == "go.mod" {
mods = append(mods, filepath.Dir(p))
}
return nil
})
if err != nil {
return nil, err
}
seen := map[string]bool{}
for _, m := range mods {
modPath, err := modulePath(filepath.Join(m, "go.mod"))
if err != nil {
return nil, err
}
err = filepath.WalkDir(m, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() {
return nil
}
if p != m {
if skipped(d.Name()) {
return filepath.SkipDir
}
if _, err := os.Stat(filepath.Join(p, "go.mod")); err == nil {
return filepath.SkipDir // a module of its own, walked on its own
}
}
isMain, err := mainPackage(p)
if err != nil || !isMain {
return err
}
name := filepath.Base(p)
if p == m {
name = pathBase(modPath)
}
for _, dir := range []string{p, m} {
out := filepath.Join(dir, name)
if st, err := os.Stat(out); err == nil && st.IsDir() {
continue // go build refuses to write over a directory, so no program lands there
}
rel, _ := filepath.Rel(root, out)
seen[filepath.ToSlash(rel)] = true
}
return nil
})
if err != nil {
return nil, err
}
}
out := make([]string, 0, len(seen))
for p := range seen {
out = append(out, p)
}
sort.Strings(out)
return out, nil
}
// Unignored names each of programs (paths from root) that root's ignore files do not ignore: a program a local build
// leaves there that `git add -A` would commit.
func Unignored(root string, programs []string) ([]string, 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)
}
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", "-n", "-z", "--stdin")
cmd.Dir = abs
cmd.Stdin = strings.NewReader(strings.Join(programs, "\x00") + "\x00")
out, err := cmd.Output()
if ee, ok := err.(*exec.ExitError); !(err == nil || ok && ee.ExitCode() == 1) {
return nil, fmt.Errorf("git check-ignore: %w", err)
}
// -v -n -z answers source, line, pattern and path for every path asked; the source is empty for one no pattern
// matches, and a pattern starting with ! keeps the path.
f := strings.Split(strings.TrimRight(string(out), "\x00"), "\x00")
var open []string
for i := 0; i+3 < len(f); i += 4 {
if f[i+2] == "" || strings.HasPrefix(f[i+2], "!") {
open = append(open, f[i+3])
}
}
return open, nil
}
func skipped(name string) bool {
switch name {
case ".git", "node_modules", "testdata", "vendor":
return true
}
return false
}
// modulePath reads the module line of a go.mod.
func modulePath(goMod string) (string, error) {
b, err := os.ReadFile(goMod)
if err != nil {
return "", err
}
for _, l := range strings.Split(string(b), "\n") {
if f := strings.Fields(l); len(f) >= 2 && f[0] == "module" {
return strings.Trim(f[1], `"`), nil
}
}
return "", fmt.Errorf("%s names no module", goMod)
}
// pathBase is the name `go build` gives a program at a module's root: its path's last element, a major version
// suffix (/v2) left out.
func pathBase(modPath string) string {
parts := strings.Split(modPath, "/")
last := parts[len(parts)-1]
if len(parts) > 1 && len(last) > 1 && last[0] == 'v' && strings.Trim(last[1:], "0123456789") == "" {
last = parts[len(parts)-2]
}
return last
}
// mainPackage is whether dir holds a Go file, not a test, of package main.
func mainPackage(dir string) (bool, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return false, err
}
for _, e := range entries {
n := e.Name()
if e.IsDir() || filepath.Ext(n) != ".go" || strings.HasSuffix(n, "_test.go") {
continue
}
b, err := os.ReadFile(filepath.Join(dir, n))
if err != nil {
return false, err
}
for _, l := range strings.Split(string(b), "\n") {
if f := strings.Fields(l); len(f) >= 2 && f[0] == "package" {
if f[1] == "main" {
return true, nil
}
break
}
}
}
return false, nil
}