// 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 ./` 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 }