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
293 lines
9.1 KiB
Go
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
|
|
}
|