// Copyright (C) 2026 Dcntrlized // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published // by the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package licenser import ( "context" "errors" "os" "os/exec" "path/filepath" "strings" "testing" ) func TestParseIgnore(t *testing.T) { got := ParseIgnore("# comment\n\n**/*.md\n vendor/** \n*_test.go\n") want := []string{"**/*.md", "vendor/**", "*_test.go"} if len(got) != len(want) { t.Fatalf("got %v want %v", got, want) } for i := range want { if got[i] != want[i] { t.Fatalf("got %v want %v", got, want) } } } func TestChangedFiles(t *testing.T) { porcelain := " M internal/a.go\n?? new file.go\nA added.go\n M \"docs/with space.md\"\n" got := ChangedFiles(porcelain) want := []string{"internal/a.go", "new file.go", "added.go", "docs/with space.md"} if len(got) != len(want) { t.Fatalf("got %v want %v", got, want) } for i := range want { if got[i] != want[i] { t.Fatalf("got %v want %v", got, want) } } } func TestRedact(t *testing.T) { in := "fatal: unable to access 'http://oauth2:supersecret@192.168.122.183:3000/o/r.git/'" got := Redact(in) if strings.Contains(got, "supersecret") { t.Fatalf("token leaked: %s", got) } if !strings.Contains(got, "http://192.168.122.183:3000/o/r.git") { t.Fatalf("host lost: %s", got) } } // TestEnsureHeadersEndToEnd exercises the real flow — clone, addlicense, commit // a separate commit, fast-forward push — against a local bare repository // standing in for the fork or base repo. func TestEnsureHeadersEndToEnd(t *testing.T) { bin := os.Getenv("ADDLICENSE_BIN") if bin == "" { bin = "addlicense" } if _, err := exec.LookPath(bin); err != nil { t.Skipf("addlicense binary not available: %v", err) } root := t.TempDir() work := filepath.Join(root, "work") bare := filepath.Join(root, "remote.git") mustGit(t, root, "init", "-q", "--initial-branch=main", work) mustGit(t, work, "config", "user.name", "Tester") mustGit(t, work, "config", "user.email", "tester@example.local") writeFile(t, filepath.Join(work, ".license-header.tpl"), "Copyright (C) {{.Year}} {{.Holder}}\n\nGNU Affero General Public License v3.\n") writeFile(t, filepath.Join(work, ".licenseignore"), "# skip docs\n**/*.md\n") writeFile(t, filepath.Join(work, "main.go"), "package main\n") mustGit(t, work, "add", "-A") mustGit(t, work, "commit", "-qm", "initial") mustGit(t, root, "init", "-q", "--bare", "--initial-branch=main", bare) mustGit(t, work, "remote", "add", "origin", bare) mustGit(t, work, "push", "-q", "-u", "origin", "main") mustGit(t, work, "checkout", "-qb", "feature") writeFile(t, filepath.Join(work, "new.go"), "package main\n\nfunc New() {}\n") writeFile(t, filepath.Join(work, "README.md"), "documentation without a header\n") mustGit(t, work, "add", "-A") mustGit(t, work, "commit", "-qm", "add an unlicensed file") sha := strings.TrimSpace(mustGit(t, work, "rev-parse", "HEAD")) mustGit(t, work, "push", "-q", "-u", "origin", "feature") res, err := EnsureHeaders(context.Background(), Options{ RemoteURL: bare, Ref: "feature", SHA: sha, Files: []string{"new.go", "README.md"}, AddlicenseBin: bin, Holder: "Dcntrlized", BotName: "Dcntrlized Auto CopyLeft Bot", BotEmail: "copyleft+bot@dcntrlized.org", WorkRoot: root, }) if err != nil { t.Fatalf("EnsureHeaders: %v", err) } if !res.Pushed { t.Fatalf("expected the amend to be pushed, got %+v", res) } if len(res.Changed) != 1 || res.Changed[0] != "new.go" { t.Fatalf("changed = %v, want [new.go]", res.Changed) } if res.NewSHA == "" || res.NewSHA == sha { t.Fatalf("expected a new commit, got %q (old %q)", res.NewSHA, sha) } verify := filepath.Join(root, "verify") mustGit(t, root, "clone", "-q", "--branch", "feature", bare, verify) body := readFile(t, filepath.Join(verify, "new.go")) if !strings.Contains(body, "GNU Affero General Public License") { t.Fatalf("header not added:\n%s", body) } if strings.Contains(readFile(t, filepath.Join(verify, "README.md")), "Affero") { t.Fatal("README.md must stay untouched (ignored)") } // The contributor's commit must be preserved byte-for-byte: same message, // same author, same sha. The bot only adds a commit on top of it. if got := strings.TrimSpace(mustGit(t, verify, "log", "-1", "--skip=1", "--pretty=%H")); got != sha { t.Fatalf("contributor commit sha changed: %s, want %s", got, sha) } if got := strings.TrimSpace(mustGit(t, verify, "log", "-1", "--skip=1", "--pretty=%s")); got != "add an unlicensed file" { t.Fatalf("contributor commit message changed: %q", got) } if got := strings.TrimSpace(mustGit(t, verify, "log", "-1", "--skip=1", "--pretty=%an <%ae>")); got != "Tester " { t.Fatalf("contributor commit author changed: %q", got) } if got := strings.TrimSpace(mustGit(t, verify, "log", "-1", "--pretty=%cn <%ce>")); got != "Dcntrlized Auto CopyLeft Bot " { t.Fatalf("committer should be the bot: %q", got) } if got := strings.TrimSpace(mustGit(t, verify, "log", "-1", "--pretty=%s")); got != "chore(license): add AGPL-3.0 headers" { t.Fatalf("bot commit subject = %q", got) } if body := mustGit(t, verify, "log", "-1", "--pretty=%b"); !strings.Contains(body, "reverted") { t.Fatalf("bot commit body should say the commit is revertable: %q", body) } // initial + contributor + bot, and the bot's commit sits directly on top. if got := strings.TrimSpace(mustGit(t, verify, "rev-list", "--count", "HEAD")); got != "3" { t.Fatalf("expected initial + contributor + bot commit, got %s", got) } if got := strings.TrimSpace(mustGit(t, verify, "rev-parse", "HEAD^")); got != sha { t.Fatalf("bot commit parent = %s, want %s", got, sha) } } // TestEnsureHeadersRemoteMoved pins the self-healing race behavior: when the // branch advances after the bot read it, the fast-forward push is rejected, the // bot drops its local work, re-reads the branch, re-runs the check on top of the // new tip and pushes again. Nothing is overwritten and nothing is lost. func TestEnsureHeadersRemoteMoved(t *testing.T) { bin := os.Getenv("ADDLICENSE_BIN") if bin == "" { bin = "addlicense" } if _, err := exec.LookPath(bin); err != nil { t.Skipf("addlicense binary not available: %v", err) } root := t.TempDir() work := filepath.Join(root, "work") bare := filepath.Join(root, "remote.git") mustGit(t, root, "init", "-q", "--initial-branch=main", work) mustGit(t, work, "config", "user.name", "Tester") mustGit(t, work, "config", "user.email", "tester@example.local") writeFile(t, filepath.Join(work, ".license-header.tpl"), "Copyright (C) {{.Year}} {{.Holder}}\n") writeFile(t, filepath.Join(work, "main.go"), "package main\n") mustGit(t, work, "add", "-A") mustGit(t, work, "commit", "-qm", "initial") mustGit(t, root, "init", "-q", "--bare", "--initial-branch=main", bare) mustGit(t, work, "remote", "add", "origin", bare) mustGit(t, work, "push", "-q", "-u", "origin", "main") mustGit(t, work, "checkout", "-qb", "feature") writeFile(t, filepath.Join(work, "new.go"), "package main\n\nfunc New() {}\n") mustGit(t, work, "add", "-A") mustGit(t, work, "commit", "-qm", "add an unlicensed file") staleSHA := strings.TrimSpace(mustGit(t, work, "rev-parse", "HEAD")) mustGit(t, work, "push", "-q", "-u", "origin", "feature") // Someone else pushes to the same branch while the bot is working. writeFile(t, filepath.Join(work, "new.go"), "package main\n\nfunc New() {}\n\nfunc Later() {}\n") mustGit(t, work, "add", "-A") mustGit(t, work, "commit", "-qm", "work pushed concurrently") movedSHA := strings.TrimSpace(mustGit(t, work, "rev-parse", "HEAD")) mustGit(t, work, "push", "-q", "origin", "feature") res, err := EnsureHeaders(context.Background(), Options{ RemoteURL: bare, Ref: "feature", SHA: staleSHA, Files: []string{"new.go"}, AddlicenseBin: bin, Holder: "Dcntrlized", BotName: "Bot", BotEmail: "bot@example.local", WorkRoot: root, }) if err != nil { t.Fatalf("the bot must self-heal a moved branch, got %v (res %+v)", err, res) } if !res.Pushed || res.NewSHA == "" { t.Fatalf("expected the retried commit to be pushed, got %+v", res) } if len(res.Changed) != 1 || res.Changed[0] != "new.go" { t.Fatalf("changed = %v, want [new.go]", res.Changed) } verify := filepath.Join(root, "verify") mustGit(t, root, "clone", "-q", "--branch", "feature", bare, verify) if got := strings.TrimSpace(mustGit(t, verify, "rev-parse", "HEAD")); got != res.NewSHA { t.Fatalf("remote head = %s, want the bot commit %s", got, res.NewSHA) } // The bot's commit sits on top of the CONCURRENT commit, not the stale one. if got := strings.TrimSpace(mustGit(t, verify, "rev-parse", "HEAD^")); got != movedSHA { t.Fatalf("bot commit parent = %s, want the concurrent commit %s", got, movedSHA) } // Nothing may be lost: the concurrent change and both original commits stay. if got := strings.TrimSpace(mustGit(t, verify, "rev-list", "--count", "HEAD")); got != "4" { t.Fatalf("expected initial + contributor + concurrent + bot, got %s commits", got) } if got := strings.TrimSpace(mustGit(t, verify, "rev-parse", "HEAD~2")); got != staleSHA { t.Fatalf("the contributor commit changed: %s, want %s", got, staleSHA) } body := readFile(t, filepath.Join(verify, "new.go")) if !strings.Contains(body, "Copyright (C)") { t.Fatalf("header not applied after the retry:\n%s", body) } if !strings.Contains(body, "func Later()") { t.Fatalf("the concurrent change was lost:\n%s", body) } } // TestEnsureHeadersRemoteMovedAlreadyFixed covers the retry that finds nothing // left to do because the concurrent push added the header itself: no commit is // published and the branch is left exactly as the other writer left it. func TestEnsureHeadersRemoteMovedAlreadyFixed(t *testing.T) { bin := os.Getenv("ADDLICENSE_BIN") if bin == "" { bin = "addlicense" } if _, err := exec.LookPath(bin); err != nil { t.Skipf("addlicense binary not available: %v", err) } root := t.TempDir() work := filepath.Join(root, "work") bare := filepath.Join(root, "remote.git") mustGit(t, root, "init", "-q", "--initial-branch=main", work) mustGit(t, work, "config", "user.name", "Tester") mustGit(t, work, "config", "user.email", "tester@example.local") writeFile(t, filepath.Join(work, ".license-header.tpl"), "Copyright (C) {{.Year}} {{.Holder}}\n") writeFile(t, filepath.Join(work, "main.go"), "package main\n") mustGit(t, work, "add", "-A") mustGit(t, work, "commit", "-qm", "initial") mustGit(t, root, "init", "-q", "--bare", "--initial-branch=main", bare) mustGit(t, work, "remote", "add", "origin", bare) mustGit(t, work, "push", "-q", "-u", "origin", "main") mustGit(t, work, "checkout", "-qb", "feature") writeFile(t, filepath.Join(work, "new.go"), "package main\n\nfunc New() {}\n") mustGit(t, work, "add", "-A") mustGit(t, work, "commit", "-qm", "add an unlicensed file") staleSHA := strings.TrimSpace(mustGit(t, work, "rev-parse", "HEAD")) mustGit(t, work, "push", "-q", "-u", "origin", "feature") // The concurrent writer licenses the file itself. writeFile(t, filepath.Join(work, "new.go"), "// Copyright (C) 2026 Dcntrlized\n\npackage main\n\nfunc New() {}\n") mustGit(t, work, "add", "-A") mustGit(t, work, "commit", "-qm", "license the file by hand") movedSHA := strings.TrimSpace(mustGit(t, work, "rev-parse", "HEAD")) mustGit(t, work, "push", "-q", "origin", "feature") res, err := EnsureHeaders(context.Background(), Options{ RemoteURL: bare, Ref: "feature", SHA: staleSHA, Files: []string{"new.go"}, AddlicenseBin: bin, Holder: "Dcntrlized", BotName: "Bot", BotEmail: "bot@example.local", WorkRoot: root, }) if err != nil { t.Fatalf("EnsureHeaders: %v", err) } if res.Pushed || res.NewSHA != "" { t.Fatalf("nothing to publish, got %+v", res) } if len(res.Changed) != 0 { t.Fatalf("changed = %v, want none", res.Changed) } verify := filepath.Join(root, "verify") mustGit(t, root, "clone", "-q", "--branch", "feature", bare, verify) if got := strings.TrimSpace(mustGit(t, verify, "rev-parse", "HEAD")); got != movedSHA { t.Fatalf("remote head = %s, want %s (untouched)", got, movedSHA) } if got := strings.TrimSpace(mustGit(t, verify, "rev-list", "--count", "HEAD")); got != "3" { t.Fatalf("no commit may be added, got %s", got) } } // TestEnsureHeadersGivesUpWhenBranchKeepsMoving bounds the retry loop: if every // push is rejected as a non-fast-forward, the attempt budget is exhausted, the // error is ErrRemoteMoved and nothing is published. func TestEnsureHeadersGivesUpWhenBranchKeepsMoving(t *testing.T) { bin := os.Getenv("ADDLICENSE_BIN") if bin == "" { bin = "addlicense" } if _, err := exec.LookPath(bin); err != nil { t.Skipf("addlicense binary not available: %v", err) } root := t.TempDir() work := filepath.Join(root, "work") bare := filepath.Join(root, "remote.git") mustGit(t, root, "init", "-q", "--initial-branch=main", work) mustGit(t, work, "config", "user.name", "Tester") mustGit(t, work, "config", "user.email", "tester@example.local") writeFile(t, filepath.Join(work, ".license-header.tpl"), "Copyright (C) {{.Year}} {{.Holder}}\n") writeFile(t, filepath.Join(work, "main.go"), "package main\n") mustGit(t, work, "add", "-A") mustGit(t, work, "commit", "-qm", "initial") mustGit(t, root, "init", "-q", "--bare", "--initial-branch=main", bare) mustGit(t, work, "remote", "add", "origin", bare) mustGit(t, work, "push", "-q", "-u", "origin", "main") mustGit(t, work, "checkout", "-qb", "feature") writeFile(t, filepath.Join(work, "new.go"), "package main\n\nfunc New() {}\n") mustGit(t, work, "add", "-A") mustGit(t, work, "commit", "-qm", "add an unlicensed file") sha := strings.TrimSpace(mustGit(t, work, "rev-parse", "HEAD")) mustGit(t, work, "push", "-q", "-u", "origin", "feature") hook := filepath.Join(bare, "hooks", "pre-receive") writeFile(t, hook, "#!/bin/sh\necho '! [rejected] feature -> feature (non-fast-forward)' >&2\nexit 1\n") if err := os.Chmod(hook, 0o755); err != nil { t.Fatal(err) } res, err := EnsureHeaders(context.Background(), Options{ RemoteURL: bare, Ref: "feature", SHA: sha, Files: []string{"new.go"}, AddlicenseBin: bin, Holder: "Dcntrlized", BotName: "Bot", BotEmail: "bot@example.local", WorkRoot: root, }) if !errors.Is(err, ErrRemoteMoved) { t.Fatalf("err = %v, want ErrRemoteMoved (res %+v)", err, res) } if res == nil || res.Pushed { t.Fatalf("nothing may be published when the branch keeps moving: %+v", res) } if res.NewSHA != "" { t.Fatalf("no remote commit may be reported, got %q", res.NewSHA) } verify := filepath.Join(root, "verify") mustGit(t, root, "clone", "-q", "--branch", "feature", bare, verify) if got := strings.TrimSpace(mustGit(t, verify, "rev-parse", "HEAD")); got != sha { t.Fatalf("remote head = %s, want %s", got, sha) } } // TestEnsureHeadersPushRefused covers the allow_maintainer_edit=false case: the // server refuses the force-push, and the bot must report Blocked instead of an // error so it can tell the contributor to add the header by hand. func TestEnsureHeadersPushRefused(t *testing.T) { bin := os.Getenv("ADDLICENSE_BIN") if bin == "" { bin = "addlicense" } if _, err := exec.LookPath(bin); err != nil { t.Skipf("addlicense binary not available: %v", err) } root := t.TempDir() work := filepath.Join(root, "work") bare := filepath.Join(root, "remote.git") mustGit(t, root, "init", "-q", "--initial-branch=main", work) mustGit(t, work, "config", "user.name", "Tester") mustGit(t, work, "config", "user.email", "tester@example.local") writeFile(t, filepath.Join(work, ".license-header.tpl"), "Copyright (C) {{.Year}} {{.Holder}}\n") writeFile(t, filepath.Join(work, "main.go"), "package main\n") mustGit(t, work, "add", "-A") mustGit(t, work, "commit", "-qm", "initial") mustGit(t, root, "init", "-q", "--bare", "--initial-branch=main", bare) mustGit(t, work, "remote", "add", "origin", bare) mustGit(t, work, "push", "-q", "-u", "origin", "main") mustGit(t, work, "checkout", "-qb", "feature") writeFile(t, filepath.Join(work, "new.go"), "package main\n\nfunc New() {}\n") mustGit(t, work, "add", "-A") mustGit(t, work, "commit", "-qm", "add an unlicensed file") sha := strings.TrimSpace(mustGit(t, work, "rev-parse", "HEAD")) mustGit(t, work, "push", "-q", "-u", "origin", "feature") hook := filepath.Join(bare, "hooks", "pre-receive") writeFile(t, hook, "#!/bin/sh\necho 'Forbidden: maintainer edits are disabled for this pull request' >&2\nexit 1\n") if err := os.Chmod(hook, 0o755); err != nil { t.Fatal(err) } res, err := EnsureHeaders(context.Background(), Options{ RemoteURL: bare, Ref: "feature", SHA: sha, Files: []string{"new.go"}, AddlicenseBin: bin, Holder: "Dcntrlized", BotName: "Bot", BotEmail: "bot@example.local", WorkRoot: root, }) if err != nil { t.Fatalf("a refused push must not be a hard error: %v", err) } if !res.Blocked || res.Pushed { t.Fatalf("expected Blocked, got %+v", res) } if res.NewSHA == "" || res.NewSHA == sha { t.Fatalf("the amended commit should still be reported: %+v", res) } if !strings.Contains(res.BlockedReason, "Forbidden") { t.Fatalf("reason = %q", res.BlockedReason) } // The refusal must leave the remote branch untouched. verify := filepath.Join(root, "verify") mustGit(t, root, "clone", "-q", "--branch", "feature", bare, verify) if got := strings.TrimSpace(mustGit(t, verify, "rev-parse", "HEAD")); got != sha { t.Fatalf("remote head = %s, want %s", got, sha) } } func mustGit(t *testing.T, dir string, args ...string) string { t.Helper() cmd := exec.Command("git", args...) cmd.Dir = dir out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out) } return string(out) } func writeFile(t *testing.T, path, body string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte(body), 0o644); err != nil { t.Fatal(err) } } func readFile(t *testing.T, path string) string { t.Helper() b, err := os.ReadFile(path) if err != nil { t.Fatal(err) } return string(b) }