From 4d3c901b99c3c79234c190f0e9410e0c64b4aedc Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 19:39:33 +0100 Subject: [PATCH] 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 --- .gitignore | 271 ++++++++++++++++++++++----- checks/gitignored/gitignored.go | 168 +++++++++++++++++ checks/gitignored/gitignored_test.go | 78 ++++++++ 3 files changed, 475 insertions(+), 42 deletions(-) diff --git a/.gitignore b/.gitignore index ada6bda3f..9c2c0c893 100644 --- a/.gitignore +++ b/.gitignore @@ -1,49 +1,236 @@ node_modules/ dist/ - -# Go tool bundles built in place (go build in a module's cmd/-tools) are build output. -modules/pacman/pacman-tools -modules/slack/cmd/slack-tools/slack-tools -modules/jetbrains-toolbox/cmd/toolbox-tools/toolbox-tools -modules/messenger/cmd/messenger/messenger -modules/mesh-watcher/cmd/mesh-watcher/mesh-watcher -modules/mesh-conditions/mesh-conditions -modules/minio/object-store -modules/claude-code/cmd/claude-code/claude-code -modules/dnsmasq/cmd/dnsmasq-tools/dnsmasq-tools -# ...and the same built from the module's root (go build ./cmd/), which names it after the module. -modules/messenger/messenger -modules/claude-code/claude-code -modules/mesh-watcher/mesh-watcher -modules/dnsmasq/dnsmasq -modules/mesh-issues/cmd/mesh-issues/mesh-issues -modules/mesh-issues/mesh-issues -modules/mounts/cmd/mounts/mounts -modules/mesh-knowledge/cmd/mesh-knowledge/mesh-knowledge -modules/mesh-knowledge/mesh-knowledge -modules/mesh-reads/mesh-reads -modules/mesh-reads/cmd/mesh-reads/mesh-reads -modules/mesh-files/mesh-files -modules/mesh-files/cmd/mesh-files/mesh-files - -# The provisioner client built in place (issue 671). -modules/mesh-provisioner-client/cmd/mesh-provisioner-client/mesh-provisioner-client -modules/brother/cmd/brother/brother -modules/mesh-build-go/cmd/mesh-build-go/mesh-build-go -modules/mesh-config/mesh-config -modules/mesh-build-image/cmd/mesh-build-image/mesh-build-image *.test -# binaries a local go build leaves in a module root -/checks/paths/paths +# Every program a local `go build` leaves, in its main package's directory and at its Go module's root, each anchored to +# its one path so it never hides source (issue 1577: twice in one milestone a module's program was committed). The +# checks/gitignored test refuses a main package whose program no ignore file names: add its line here. +/checks/agenthome/agenthome +/checks/config/config +/checks/deps/deps /checks/environment/environment +/checks/imagebuild/imagebuild +/checks/imageplace/imageplace +/checks/locks/locks +/checks/mailstore/mailstore +/checks/noplacement/noplacement +/checks/owned/owned +/checks/particulars/particulars +/checks/paths/paths +/checks/predefined/predefined +/checks/readable/readable +/checks/removedpackages/removedpackages +/checks/secrets/secrets +/checks/sshdir/sshdir +/checks/templates/templates +/checks/toolchains/toolchains +/checks/words/words +/modules/adwaita/adwaita-tools +/modules/adwaita/cmd/adwaita-tools/adwaita-tools +/modules/artifact-store-tools/artifact-store-tools +/modules/artifact-store-tools/cmd/artifact-store-tools/artifact-store-tools +/modules/asus-zephyrus-g14/cmd/zephyrus/zephyrus +/modules/asus-zephyrus-g14/zephyrus +/modules/avahi/avahi-tools +/modules/avahi/cmd/avahi-tools/avahi-tools +/modules/blueman/blueman-tools +/modules/blueman/cmd/blueman-tools/blueman-tools +/modules/bluetooth/bluetooth-tools +/modules/bluetooth/cmd/bluetooth-tools/bluetooth-tools +/modules/brother/brother +/modules/brother/cmd/brother/brother +/modules/claude-code/claude-code +/modules/claude-code/cmd/claude-code/claude-code +/modules/claude-licence-manager/claude-licence-manager +/modules/claude-licence-manager/cmd/claude-licence-manager/claude-licence-manager +/modules/clipmenu/clipmenu-tools +/modules/clipmenu/cmd/clipmenu-tools/clipmenu-tools +/modules/cups/cmd/cups-tools/cups-tools +/modules/cups/cups-tools +/modules/dbus/cmd/dbus-tools/dbus-tools +/modules/dbus/dbus-tools +/modules/desk-channel/cmd/desk-channel/desk-channel +/modules/desk-channel/desk-channel +/modules/disk-load/cmd/disk-load/disk-load +/modules/disk-load/disk-load +/modules/distribution/cmd/distribution-tools/distribution-tools +/modules/distribution/cmd/distribution/distribution +/modules/distribution/distribution +/modules/distribution/distribution-tools +/modules/dmenu/cmd/dmenu-tools/dmenu-tools +/modules/dmenu/dmenu-tools +/modules/dnsmasq/cmd/dnsmasq-tools/dnsmasq-tools +/modules/dnsmasq/dnsmasq-tools +/modules/docker-compose/cmd/docker-compose-tools/docker-compose-tools +/modules/docker-compose/docker-compose-tools +/modules/docker/cmd/docker-tools/docker-tools +/modules/docker/docker-tools +/modules/dunst/cmd/dunst-tools/dunst-tools +/modules/dunst/dunst-tools +/modules/fail2ban/cmd/fail2ban-tools/fail2ban-tools +/modules/fail2ban/fail2ban-tools +/modules/feh/cmd/feh-tools/feh-tools +/modules/feh/feh-tools +/modules/flatpak/cmd/flatpak-tools/flatpak-tools +/modules/flatpak/flatpak-tools +/modules/fonts/cmd/fonts-tools/fonts-tools +/modules/fonts/fonts-tools +/modules/forticlient/cmd/forticlient-tools/forticlient-tools +/modules/forticlient/forticlient-tools +/modules/gitea/agent-teams +/modules/gitea/agent-token +/modules/gitea/cmd/agent-teams/agent-teams +/modules/gitea/cmd/agent-token/agent-token +/modules/gitea/cmd/forge-account-provider/forge-account-provider +/modules/gitea/cmd/forge/forge +/modules/gitea/cmd/package-registry/package-registry +/modules/gitea/cmd/repositories/repositories +/modules/gitea/forge +/modules/gitea/forge-account-provider +/modules/gitea/package-registry +/modules/gitea/repositories +/modules/gnome-keyring/cmd/gnome-keyring-tools/gnome-keyring-tools +/modules/gnome-keyring/gnome-keyring-tools +/modules/hostname/cmd/hostname-tools/hostname-tools +/modules/hostname/hostname-tools +/modules/i3/cmd/i3-tools/i3-tools +/modules/i3/i3-tools +/modules/i3status-rust/cmd/i3status-rust-tools/i3status-rust-tools +/modules/i3status-rust/i3status-rust-tools +/modules/kernel/cmd/kernel-tools/kernel-tools +/modules/kernel/kernel-tools +/modules/keycloak/cmd/keycloak-provider/keycloak-provider +/modules/keycloak/keycloak-provider +/modules/lemurs/cmd/lemurs-tools/lemurs-tools +/modules/lemurs/lemurs-tools +/modules/localization/cmd/localization-tools/localization-tools +/modules/localization/localization-tools +/modules/logrotate/cmd/logrotate-tools/logrotate-tools +/modules/logrotate/logrotate-tools +/modules/mailu/automx +/modules/mailu/cmd/automx/automx +/modules/memory-pressure/cmd/memory-pressure/memory-pressure +/modules/memory-pressure/memory-pressure +/modules/mesh-build-go/buildstep/cmd/mesh-pack/mesh-pack +/modules/mesh-build-go/buildstep/cmd/seatgen/seatgen +/modules/mesh-build-go/buildstep/mesh-pack +/modules/mesh-build-go/cmd/mesh-build-go/mesh-build-go /modules/mesh-build-go/mesh-build-go +/modules/mesh-build-image/cmd/mesh-build-image/mesh-build-image +/modules/mesh-build-image/mesh-build-image +/modules/mesh-build-node/cmd/mesh-build-node/mesh-build-node +/modules/mesh-build-node/mesh-build-node +/modules/mesh-cli/cmd/mesh-cli/mesh-cli +/modules/mesh-cli/mesh-cli +/modules/mesh-conditions/cmd/mesh-conditions/mesh-conditions +/modules/mesh-conditions/mesh-conditions +/modules/mesh-config/cmd/mesh-config/mesh-config +/modules/mesh-config/mesh-config +/modules/mesh-delivery-gate/cmd/mesh-delivery-gate/mesh-delivery-gate /modules/mesh-delivery-gate/mesh-delivery-gate -modules/mesh-config/cmd/mesh-config/mesh-config - -# Built by their modules' build step (issue 1577), never committed. -modules/forticlient/cmd/forticlient-tools/forticlient-tools -modules/networkmanager/uplink-tools -checks/words/words -modules/mesh-cli/mesh-cli -modules/mesh-cli/cmd/mesh-cli/mesh-cli +/modules/mesh-files/cmd/mesh-files/mesh-files +/modules/mesh-files/mesh-files +/modules/mesh-hand-acts/cmd/mesh-hand-acts/mesh-hand-acts +/modules/mesh-hand-acts/mesh-hand-acts +/modules/mesh-healers/cmd/mesh-healers/mesh-healers +/modules/mesh-healers/mesh-healers +/modules/mesh-issues/cmd/mesh-issues/mesh-issues +/modules/mesh-issues/mesh-issues +/modules/mesh-knowledge/cmd/mesh-knowledge/mesh-knowledge +/modules/mesh-knowledge/mesh-knowledge +/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/mesh-mail-notifier +/modules/mesh-mail-notifier/mesh-mail-notifier +/modules/mesh-mail-reader/cmd/mesh-mail-reader/mesh-mail-reader +/modules/mesh-mail-reader/mesh-mail-reader +/modules/mesh-mail-web/cmd/mesh-mail-web/mesh-mail-web +/modules/mesh-mail-web/mesh-mail-web +/modules/mesh-mail/cmd/mesh-mail/mesh-mail +/modules/mesh-mail/mesh-mail +/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/mesh-provisioner-client +/modules/mesh-provisioner-client/mesh-provisioner-client +/modules/mesh-provisioner/cmd/mesh-provisioner/mesh-provisioner +/modules/mesh-provisioner/mesh-provisioner +/modules/mesh-reads/cmd/mesh-reads/mesh-reads +/modules/mesh-reads/mesh-reads +/modules/mesh-self-check/cmd/mesh-self-check/mesh-self-check +/modules/mesh-self-check/mesh-self-check +/modules/mesh-vault/cmd/mesh-vault/mesh-vault +/modules/mesh-vault/mesh-vault +/modules/mesh-watcher/cmd/mesh-watcher/mesh-watcher +/modules/mesh-watcher/mesh-watcher +/modules/messenger/cmd/messenger/messenger +/modules/messenger/messenger +/modules/minio/cmd/object-store/object-store +/modules/minio/object-store +/modules/mounts/cmd/mounts/mounts +/modules/mounts/mounts +/modules/nats/cmd/mesh-nats-snapshot/mesh-nats-snapshot +/modules/nats/cmd/nats-tools/nats-tools +/modules/nats/mesh-nats-snapshot +/modules/nats/nats-tools +/modules/netcheck/cmd/netcheck/netcheck +/modules/netcheck/netcheck +/modules/networkmanager/cmd/uplink-tools/uplink-tools +/modules/networkmanager/uplink-tools +/modules/nextcloud-client/cmd/nextcloud-client-tools/nextcloud-client-tools +/modules/nextcloud-client/nextcloud-client-tools +/modules/nfs-server/cmd/nfs-server/nfs-server +/modules/nfs-server/nfs-server +/modules/nm-applet/cmd/nm-applet-tools/nm-applet-tools +/modules/nm-applet/nm-applet-tools +/modules/only-office/cmd/only-office/only-office +/modules/only-office/only-office +/modules/openrazer/cmd/openrazer-tools/openrazer-tools +/modules/openrazer/openrazer-tools +/modules/pacman/cmd/pacman-tools/pacman-tools +/modules/pacman/pacman-tools +/modules/picom/cmd/picom-tools/picom-tools +/modules/picom/picom-tools +/modules/polychromatic/cmd/polychromatic-tools/polychromatic-tools +/modules/polychromatic/polychromatic-tools +/modules/postgres/cmd/postgres-provider/postgres-provider +/modules/postgres/postgres-provider +/modules/power/cmd/power/power +/modules/power/power +/modules/records/cmd/records/records +/modules/records/records +/modules/restic/cmd/restic-backups/restic-backups +/modules/restic/restic-backups +/modules/rofi/cmd/rofi-tools/rofi-tools +/modules/rofi/rofi-tools +/modules/route-adapter/cmd/route-provider/route-provider +/modules/route-adapter/route-provider +/modules/screen-lock/cmd/screen-lock-tools/screen-lock-tools +/modules/screen-lock/screen-lock-tools +/modules/sensors/cmd/sensors/sensors +/modules/sensors/sensors +/modules/slack/cmd/slack-tools/slack-tools +/modules/slack/slack-tools +/modules/snapd/cmd/snapd-tools/snapd-tools +/modules/snapd/snapd-tools +/modules/ssh-client/cmd/ssh-client-tools/ssh-client-tools +/modules/ssh-client/ssh-client-tools +/modules/sudo/cmd/sudo-tools/sudo-tools +/modules/sudo/sudo-tools +/modules/systemd-networkd/cmd/uplink-tools/uplink-tools +/modules/systemd-networkd/uplink-tools +/modules/systemd-resolved/cmd/resolver-tools/resolver-tools +/modules/systemd-resolved/resolver-tools +/modules/systemd/cmd/systemd-tools/systemd-tools +/modules/systemd/systemd-tools +/modules/telegram/cmd/telegram/telegram +/modules/telegram/telegram +/modules/time-sync/cmd/time-sync-tools/time-sync-tools +/modules/time-sync/time-sync-tools +/modules/triggerhappy/cmd/triggerhappy-tools/triggerhappy-tools +/modules/triggerhappy/triggerhappy-tools +/modules/xclip/cmd/xclip-tools/xclip-tools +/modules/xclip/xclip-tools +/modules/xdg/cmd/xdg-tools/xdg-tools +/modules/xdg/xdg-tools +/modules/xorg/cmd/xorg/xorg +/modules/xorg/xorg +/modules/xterm/cmd/xterm-tools/xterm-tools +/modules/xterm/xterm-tools +/modules/zfs/cmd/zfs-tools/zfs-tools +/modules/zfs/zfs-tools diff --git a/checks/gitignored/gitignored.go b/checks/gitignored/gitignored.go index e33f86af0..4ede4c993 100644 --- a/checks/gitignored/gitignored.go +++ b/checks/gitignored/gitignored.go @@ -122,3 +122,171 @@ func Find(root string, dirs []string) ([]Hidden, error) { } 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 +} diff --git a/checks/gitignored/gitignored_test.go b/checks/gitignored/gitignored_test.go index 304e444f6..ff1f40d05 100644 --- a/checks/gitignored/gitignored_test.go +++ b/checks/gitignored/gitignored_test.go @@ -78,3 +78,81 @@ func TestNoIgnorePatternHidesAModulesSource(t *testing.T) { 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) } } + +// The kind of issue 1577, met twice in one milestone: a module's program `go build` left in its folder was committed. +// Every program a main package builds, in its own directory and at its module's root, is found, and one no ignore file +// names is open; a directory of the same name is no program's place. +func TestEveryProgramABuildLeavesIsIgnored(t *testing.T) { + files := map[string]string{ + "modules/m/go.mod": "module example.com/m/v2\n", + "modules/m/main.go": "// a program\npackage main\n", + "modules/m/cmd/m-tools/main.go": "package main\n", + "modules/m/cmd/m-tools/main_test.go": "package main\n", + "modules/m/internal/x/x.go": "package x\n", + "modules/m/internal/x/x_test.go": "package main\n", + "modules/m/node_modules/y/y.go": "package main\n", + "modules/m/tool/go.mod": "module tool\n", + "modules/m/tool/cmd/tool/main.go": "package main\n", + "modules/m/tool/tool/source.go": "package tool\n", + "checks/c/go.mod": "module example.com/checks/c\n", + "checks/c/main.go": "package main\n", + } + for name, c := range map[string]struct { + ignore string + open []string + }{ + "nothing ignored": {"", []string{"checks/c/c", "modules/m/cmd/m-tools/m-tools", "modules/m/m", "modules/m/m-tools", + "modules/m/tool/cmd/tool/tool"}}, + "each anchored": {"/checks/c/c\n/modules/m/cmd/m-tools/m-tools\n/modules/m/m\n/modules/m/m-tools\n/modules/m/tool/cmd/tool/tool\n", nil}, + "one kept by a negation": {"/checks/c/c\n/modules/m/*\n!/modules/m/m\n/modules/m/cmd/m-tools/m-tools\n/modules/m/tool/cmd/tool/tool\n", + []string{"modules/m/m"}}, + } { + root := t.TempDir() + for f, b := range files { + 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) + } + } + if err := os.WriteFile(filepath.Join(root, ".gitignore"), []byte(c.ignore), 0o644); err != nil { + t.Fatal(err) + } + programs, err := Programs(root) + if err != nil { + t.Fatal(err) + } + want := "checks/c/c,modules/m/cmd/m-tools/m-tools,modules/m/m,modules/m/m-tools,modules/m/tool/cmd/tool/tool" + if got := strings.Join(programs, ","); got != want { + t.Fatalf("%s: programs %s, want %s", name, got, want) + } + open, err := Unignored(root, programs) + if err != nil { + t.Fatal(err) + } + if strings.Join(open, ",") != strings.Join(c.open, ",") { + t.Errorf("%s: open %v, want %v", name, open, c.open) + } + } +} + +// The catalogue's own tree: every program a local build leaves is ignored, so `git add -A` never takes one. +func TestNoProgramABuildLeavesIsCommitted(t *testing.T) { + root := filepath.Join("..", "..") + programs, err := Programs(root) + if err != nil { + t.Fatal(err) + } + if len(programs) == 0 { + t.Fatal("no main package found: the check reads nothing") + } + open, err := Unignored(root, programs) + if err != nil { + t.Fatal(err) + } + for _, p := range open { + t.Errorf("%s is the program a local go build leaves, and no ignore file names it: add /%s to the catalogue's .gitignore", p, p) + } +}