From d8e43b48c467614dfdc5d557b5b3116d645b9410 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 17:17:54 +0100 Subject: [PATCH] F-catalog-B: no ignore pattern hides a module's source; nfs-server's and restic's patterns anchored A bare pattern meant for a binary (mesh-build-node) matched the command directory of the same name, so git ignored every new file there and publish.go was never committed (catalogue review F1). The kind: every bare pattern of a module's .gitignore is anchored to the binary (nfs-server's hid all of cmd/nfs-server), and checks/gitignored refuses an ignore pattern, the module's or the catalogue's, that hides a directory under a module's cmd/ or internal/, a Go file added there, or a source file there, in a git checkout and a plain folder alike. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- checks/gitignored/gitignored.go | 124 +++++++++++++++++++ checks/gitignored/gitignored_test.go | 80 ++++++++++++ checks/gitignored/go.mod | 3 + modules/nfs-server/.gitignore | 4 +- modules/restic/cmd/restic-backups/.gitignore | 3 +- 5 files changed, 212 insertions(+), 2 deletions(-) create mode 100644 checks/gitignored/gitignored.go create mode 100644 checks/gitignored/gitignored_test.go create mode 100644 checks/gitignored/go.mod diff --git a/checks/gitignored/gitignored.go b/checks/gitignored/gitignored.go new file mode 100644 index 00000000..e33f86af --- /dev/null +++ b/checks/gitignored/gitignored.go @@ -0,0 +1,124 @@ +// 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 +} diff --git a/checks/gitignored/gitignored_test.go b/checks/gitignored/gitignored_test.go new file mode 100644 index 00000000..304e444f --- /dev/null +++ b/checks/gitignored/gitignored_test.go @@ -0,0 +1,80 @@ +package gitignored + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// The kind of catalogue review F1: a bare pattern hides the command directory it shares a name with; an anchored one +// hides the binary alone; a glob that ignores source is refused too; a negation keeps a path. +func TestFindNamesEachPatternThatHidesSource(t *testing.T) { + for name, c := range map[string]struct { + ignore string + hidden []string + }{ + "a bare name hides cmd/": {"nfs-server\n", []string{"modules/m/cmd/nfs-server/", "modules/m/cmd/nfs-server/" + Probe, "modules/m/cmd/nfs-server/main.go"}}, + "anchored to the binary": {"/nfs-server\n/cmd/nfs-server/nfs-server\n", nil}, + "a glob ignoring Go source": {"*.go\n!main.go\n", []string{"modules/m/cmd/" + Probe, "modules/m/cmd/nfs-server/" + Probe, + "modules/m/internal/" + Probe, "modules/m/internal/x/" + Probe, "modules/m/internal/x/x.go"}}, + "a pattern over internal/": {"internal/\n", []string{"modules/m/internal/", "modules/m/internal/" + Probe, "modules/m/internal/x/", + "modules/m/internal/x/" + Probe, "modules/m/internal/x/x.go"}}, + "nothing but generated JavaScript": {"*.js\n", nil}, + } { + root := t.TempDir() + for f, b := range map[string]string{ + "modules/m/.gitignore": c.ignore, + "modules/m/cmd/nfs-server/main.go": "package main\n", + "modules/m/cmd/nfs-server/run.js": "", + "modules/m/internal/x/x.go": "package x\n", + "modules/m/node_modules/y/index.go": "", + "modules/other/cmd/other/other.go": "package main\n", + } { + p := filepath.Join(root, f) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(b), 0o644); err != nil { + t.Fatal(err) + } + } + dirs, err := Sources(root) + if err != nil { + t.Fatal(err) + } + got, err := Find(root, dirs) + if err != nil { + t.Fatal(err) + } + var paths []string + for _, h := range got { + paths = append(paths, h.Path) + if !strings.HasPrefix(h.Pattern, "modules/m/.gitignore:") { + t.Errorf("%s: %s is named hidden by %q, want the module's .gitignore", name, h.Path, h.Pattern) + } + } + if strings.Join(paths, ",") != strings.Join(c.hidden, ",") { + t.Errorf("%s: hidden %v, want %v", name, paths, c.hidden) + } + } +} + +// The catalogue's own tree: no ignore pattern hides a path under a module's cmd/ or internal/. +func TestNoIgnorePatternHidesAModulesSource(t *testing.T) { + root := filepath.Join("..", "..") + dirs, err := Sources(root) + if err != nil { + t.Fatal(err) + } + if len(dirs) == 0 { + t.Fatal("no module source directory found: the check reads nothing") + } + hidden, err := Find(root, dirs) + if err != nil { + t.Fatal(err) + } + for _, h := range hidden { + t.Errorf("%s is hidden by %s: a module's source is never ignored; anchor the pattern to the binary (/, /cmd//)", h.Path, h.Pattern) + } +} diff --git a/checks/gitignored/go.mod b/checks/gitignored/go.mod new file mode 100644 index 00000000..e016e7f9 --- /dev/null +++ b/checks/gitignored/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-catalog/checks/gitignored + +go 1.22 diff --git a/modules/nfs-server/.gitignore b/modules/nfs-server/.gitignore index 3cbd8705..d52d678a 100644 --- a/modules/nfs-server/.gitignore +++ b/modules/nfs-server/.gitignore @@ -1 +1,3 @@ -nfs-server +# the binary, built at the module or in its command directory; never the command directory itself +/nfs-server +/cmd/nfs-server/nfs-server diff --git a/modules/restic/cmd/restic-backups/.gitignore b/modules/restic/cmd/restic-backups/.gitignore index 066e2897..5c650c18 100644 --- a/modules/restic/cmd/restic-backups/.gitignore +++ b/modules/restic/cmd/restic-backups/.gitignore @@ -1 +1,2 @@ -restic-backups +# the binary, built in this command directory +/restic-backups