init
This commit is contained in:
@@ -0,0 +1,355 @@
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Package licenser applies AGPL headers to a pull request's added files and
|
||||
// pushes them back as its own commit on top of the head — to the base
|
||||
// repository for same-repo PRs and to the contributor's fork for cross-repo PRs
|
||||
// (Gitea's allow_maintainer_edit, on by default). The contributor's commit is
|
||||
// never rewritten, and a branch that advances while the bot works is re-read and
|
||||
// re-processed instead of being abandoned.
|
||||
package licenser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Options describes one pull-request head to fix.
|
||||
type Options struct {
|
||||
// RemoteURL is the credential-bearing clone/push URL of the HEAD repository
|
||||
// (the fork, or the base repository for same-repo PRs).
|
||||
RemoteURL string
|
||||
// Ref is the head branch name; SHA is the head commit the PR was read at.
|
||||
// A later attempt works from the branch's current tip instead.
|
||||
Ref string
|
||||
SHA string
|
||||
// Files are repository-relative paths of changed files to check.
|
||||
Files []string
|
||||
// AddlicenseBin is the addlicense executable.
|
||||
AddlicenseBin string
|
||||
// Holder is the copyright holder passed to addlicense -c.
|
||||
Holder string
|
||||
// BotName/BotEmail are the committer identity of the amended commit.
|
||||
BotName string
|
||||
BotEmail string
|
||||
// WorkRoot is the parent directory for the temporary worktree.
|
||||
WorkRoot string
|
||||
// DryRun stops before committing/pushing.
|
||||
DryRun bool
|
||||
// Logger receives progress; may be nil.
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// Result reports what happened.
|
||||
type Result struct {
|
||||
// Changed lists files that received a header.
|
||||
Changed []string
|
||||
// NewSHA is the bot's commit on top of the pull request head, empty when
|
||||
// nothing changed.
|
||||
NewSHA string
|
||||
// Pushed reports whether that commit reached the remote.
|
||||
Pushed bool
|
||||
// Blocked reports a push refused by the server (typically
|
||||
// allow_maintainer_edit disabled on the PR, or a protected branch).
|
||||
Blocked bool
|
||||
// BlockedReason carries the redacted server message for Blocked.
|
||||
BlockedReason string
|
||||
}
|
||||
|
||||
// ErrRemoteMoved means the remote head kept advancing for every attempt: each
|
||||
// rejection makes the bot drop its local work, re-read the branch, re-apply the
|
||||
// headers and push again, and this error is only returned once that loop is
|
||||
// exhausted. Nothing is ever force-pushed or rewritten, so the caller can simply
|
||||
// try again later.
|
||||
var ErrRemoteMoved = errors.New("remote branch kept moving; will retry later")
|
||||
|
||||
// maxPushAttempts bounds the re-read/re-apply/push loop. A normal race is
|
||||
// resolved on the second attempt; the bound only stops a pathological
|
||||
// push storm from spinning forever.
|
||||
const maxPushAttempts = 5
|
||||
|
||||
const cloneDepth = 50
|
||||
|
||||
// commitSubject/commitBody describe the bot's own commit. It is deliberately a
|
||||
// new commit on top of the pull request head — never an amend — so the
|
||||
// contributor's work and message stay byte-for-byte intact and the bot's change
|
||||
// can be reverted or dropped with a single `git revert` / `git reset`.
|
||||
const commitSubject = "chore(license): add AGPL-3.0 headers"
|
||||
|
||||
// EnsureHeaders runs addlicense over opts.Files in a fresh worktree of the PR
|
||||
// head, records the result in a separate commit and pushes it on top of the
|
||||
// head branch (fast-forward, no force).
|
||||
func EnsureHeaders(ctx context.Context, opts Options) (*Result, error) {
|
||||
log := opts.Logger
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
if opts.RemoteURL == "" {
|
||||
return nil, errors.New("RemoteURL is required")
|
||||
}
|
||||
if opts.Ref == "" || opts.SHA == "" {
|
||||
return nil, errors.New("Ref and SHA are required")
|
||||
}
|
||||
|
||||
var last *Result
|
||||
for attempt := 1; attempt <= maxPushAttempts; attempt++ {
|
||||
res, moved, err := ensureHeadersOnce(ctx, opts, log, attempt == 1)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
if !moved {
|
||||
return res, nil
|
||||
}
|
||||
// The branch advanced under us: drop the local work (the temp clone is
|
||||
// discarded), re-read the branch, re-run the check and try again.
|
||||
last = res
|
||||
log.Warn("branch advanced while working; re-reading it and re-applying the headers",
|
||||
"ref", opts.Ref, "attempt", attempt, "of", maxPushAttempts)
|
||||
}
|
||||
if last != nil {
|
||||
last.NewSHA = "" // nothing reached the remote
|
||||
}
|
||||
return last, fmt.Errorf("%w: %s moved on every one of %d attempts", ErrRemoteMoved, opts.Ref, maxPushAttempts)
|
||||
}
|
||||
|
||||
// ensureHeadersOnce performs one clone/apply/commit/push round. first selects
|
||||
// the starting point: the head commit the pull request was read at on the first
|
||||
// attempt, the branch's current tip afterwards. moved reports a rejected
|
||||
// non-fast-forward push, which the caller answers by starting over.
|
||||
func ensureHeadersOnce(ctx context.Context, opts Options, log *slog.Logger, first bool) (res *Result, moved bool, err error) {
|
||||
dir, err := os.MkdirTemp(opts.WorkRoot, "license-bot-*")
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("create worktree: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
if _, err := git(ctx, dir, "clone", "--quiet", "--depth", strconv.Itoa(cloneDepth), "--single-branch", "--branch", opts.Ref, opts.RemoteURL, dir); err != nil {
|
||||
return nil, false, fmt.Errorf("clone %s: %w", Redact(opts.RemoteURL), err)
|
||||
}
|
||||
|
||||
target := opts.SHA
|
||||
if !first {
|
||||
// Re-read the branch: the previous attempt lost a race, so the work must
|
||||
// be redone on top of whatever is there now.
|
||||
if out, err := git(ctx, dir, "rev-parse", "origin/"+opts.Ref); err == nil {
|
||||
if tip := strings.TrimSpace(out); tip != "" {
|
||||
target = tip
|
||||
}
|
||||
}
|
||||
}
|
||||
if !commitExists(ctx, dir, target) {
|
||||
if _, err := git(ctx, dir, "fetch", "--quiet", "--unshallow", "origin"); err != nil {
|
||||
log.Warn("unshallow failed, using fetched history", "err", err)
|
||||
}
|
||||
}
|
||||
if !commitExists(ctx, dir, target) {
|
||||
return nil, false, fmt.Errorf("head commit %s not present in %s", target, Redact(opts.RemoteURL))
|
||||
}
|
||||
if _, err := git(ctx, dir, "checkout", "--quiet", target); err != nil {
|
||||
return nil, false, fmt.Errorf("checkout %s: %w", target, err)
|
||||
}
|
||||
|
||||
tpl := filepath.Join(dir, ".license-header.tpl")
|
||||
if _, err := os.Stat(tpl); err != nil {
|
||||
return nil, false, fmt.Errorf(".license-header.tpl missing at %s: %w", target, err)
|
||||
}
|
||||
patterns := ReadIgnorePatterns(filepath.Join(dir, ".licenseignore"))
|
||||
|
||||
res = &Result{}
|
||||
year := strconv.Itoa(time.Now().Year())
|
||||
for _, f := range opts.Files {
|
||||
if f == "" {
|
||||
continue
|
||||
}
|
||||
abs := filepath.Join(dir, filepath.FromSlash(f))
|
||||
if st, err := os.Stat(abs); err != nil || st.IsDir() {
|
||||
continue // removed again or not a regular file
|
||||
}
|
||||
args := []string{"-v", "-f", ".license-header.tpl", "-c", opts.Holder, "-y", year}
|
||||
for _, p := range patterns {
|
||||
args = append(args, "-ignore", p)
|
||||
}
|
||||
args = append(args, f)
|
||||
out, err := run(ctx, dir, opts.AddlicenseBin, args...)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("addlicense %s: %w: %s", f, err, strings.TrimSpace(out))
|
||||
}
|
||||
}
|
||||
|
||||
porcelain, err := git(ctx, dir, "status", "--porcelain")
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("git status: %w", err)
|
||||
}
|
||||
res.Changed = ChangedFiles(porcelain)
|
||||
if len(res.Changed) == 0 {
|
||||
// Everything this pull request added is already licensed, so there is
|
||||
// nothing to publish — including on a retry after a concurrent push
|
||||
// fixed the files itself.
|
||||
return res, false, nil
|
||||
}
|
||||
log.Info("adding license headers", "files", len(res.Changed), "dry_run", opts.DryRun)
|
||||
if opts.DryRun {
|
||||
return res, false, nil
|
||||
}
|
||||
|
||||
if _, err := git(ctx, dir, "add", "-A"); err != nil {
|
||||
return nil, false, fmt.Errorf("git add: %w", err)
|
||||
}
|
||||
body := "Added by the Dcntrlized license bot to the files this pull request\n" +
|
||||
"introduced without a license header. Only the files this pull request adds\n" +
|
||||
"are touched, and the header text comes from .license-header.tpl.\n\n" +
|
||||
"This commit is separate from the contributor's work, so it can be reverted\n" +
|
||||
"or dropped freely.\n\nFiles:\n"
|
||||
for _, f := range res.Changed {
|
||||
body += "- " + f + "\n"
|
||||
}
|
||||
if _, err := git(ctx, dir, "-c", "user.name="+opts.BotName, "-c", "user.email="+opts.BotEmail,
|
||||
"commit", "--quiet", "-m", commitSubject, "-m", body); err != nil {
|
||||
return nil, false, fmt.Errorf("git commit: %w", err)
|
||||
}
|
||||
newSHA, err := git(ctx, dir, "rev-parse", "HEAD")
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("rev-parse: %w", err)
|
||||
}
|
||||
res.NewSHA = strings.TrimSpace(newSHA)
|
||||
|
||||
// Fast-forward push only: no --force, no --force-with-lease. If anyone
|
||||
// pushed in the meantime the push is rejected and the caller starts over
|
||||
// from the new tip rather than rewriting their history.
|
||||
out, err := git(ctx, dir, "push", "--quiet", opts.RemoteURL, "HEAD:refs/heads/"+opts.Ref)
|
||||
if err != nil {
|
||||
msg := strings.TrimSpace(out)
|
||||
if isPushRefused(msg) {
|
||||
res.Blocked = true
|
||||
res.BlockedReason = Redact(msg)
|
||||
return res, false, nil
|
||||
}
|
||||
if isRemoteMoved(msg) {
|
||||
res.NewSHA = ""
|
||||
return res, true, nil
|
||||
}
|
||||
return res, false, fmt.Errorf("git push %s: %w: %s", Redact(opts.RemoteURL), err, Redact(msg))
|
||||
}
|
||||
res.Pushed = true
|
||||
return res, false, nil
|
||||
}
|
||||
|
||||
func commitExists(ctx context.Context, dir, sha string) bool {
|
||||
_, err := git(ctx, dir, "cat-file", "-e", sha+"^{commit}")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func git(ctx context.Context, dir string, args ...string) (string, error) {
|
||||
return runEnv(ctx, dir, []string{"GIT_TERMINAL_PROMPT=0"}, "git", args...)
|
||||
}
|
||||
|
||||
func run(ctx context.Context, dir, bin string, args ...string) (string, error) {
|
||||
return runEnv(ctx, dir, nil, bin, args...)
|
||||
}
|
||||
|
||||
// runEnv runs a command and, on failure, returns an error carrying the
|
||||
// redacted combined output so diagnostics never leak the bot token.
|
||||
func runEnv(ctx context.Context, dir string, extraEnv []string, bin string, args ...string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, bin, args...)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(), extraEnv...)
|
||||
var buf strings.Builder
|
||||
cmd.Stdout = &buf
|
||||
cmd.Stderr = &buf
|
||||
err := cmd.Run()
|
||||
out := buf.String()
|
||||
if ctx.Err() != nil {
|
||||
return out, ctx.Err()
|
||||
}
|
||||
if err != nil {
|
||||
if msg := strings.TrimSpace(Redact(out)); msg != "" {
|
||||
return out, fmt.Errorf("%w: %s", err, msg)
|
||||
}
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
|
||||
var (
|
||||
// A refusal the bot cannot fix by retrying: permissions, disabled
|
||||
// maintainer edits, protected branch rules.
|
||||
rePushDenied = regexp.MustCompile(`(?i)(403|forbidden|denied|not allowed|not permitted|permission|protected)`)
|
||||
// The remote advanced under us: a plain non-fast-forward rejection.
|
||||
reRemoteMoved = regexp.MustCompile(`(?i)(non-fast-forward|fetch first|stale info|\[remote rejected\]|behind)`)
|
||||
)
|
||||
|
||||
func isPushRefused(msg string) bool { return rePushDenied.MatchString(msg) }
|
||||
func isRemoteMoved(msg string) bool { return reRemoteMoved.MatchString(msg) }
|
||||
|
||||
// ChangedFiles extracts paths from `git status --porcelain` output.
|
||||
func ChangedFiles(porcelain string) []string {
|
||||
var out []string
|
||||
for _, line := range strings.Split(porcelain, "\n") {
|
||||
if len(line) < 4 {
|
||||
continue
|
||||
}
|
||||
p := line[3:]
|
||||
if strings.HasPrefix(p, "\"") {
|
||||
if uq, err := strconv.Unquote(p); err == nil {
|
||||
p = uq
|
||||
}
|
||||
}
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ReadIgnorePatterns returns the non-empty, non-comment lines of a
|
||||
// .licenseignore file. A missing file yields no patterns.
|
||||
func ReadIgnorePatterns(path string) []string {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return ParseIgnore(string(b))
|
||||
}
|
||||
|
||||
// ParseIgnore splits .licenseignore content into addlicense -ignore patterns.
|
||||
// addlicense's patterns are doublestar globs, not prefixes, so lines are used
|
||||
// verbatim.
|
||||
func ParseIgnore(body string) []string {
|
||||
var out []string
|
||||
for _, line := range strings.Split(body, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Redact removes credentials embedded in a URL or message so logs and PR
|
||||
// comments never leak the bot token.
|
||||
func Redact(s string) string {
|
||||
// scheme://user:pass@host -> scheme://host
|
||||
re := regexp.MustCompile(`([a-zA-Z][a-zA-Z0-9+.-]*://)[^/@\s]*@`)
|
||||
return re.ReplaceAllString(s, "$1")
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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:[email protected]: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", "[email protected]")
|
||||
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: "[email protected]",
|
||||
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 <[email protected]>" {
|
||||
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 <[email protected]>" {
|
||||
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", "[email protected]")
|
||||
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: "[email protected]", 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", "[email protected]")
|
||||
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: "[email protected]", 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", "[email protected]")
|
||||
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: "[email protected]", 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", "[email protected]")
|
||||
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: "[email protected]", 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)
|
||||
}
|
||||
Reference in New Issue
Block a user