461 lines
18 KiB
Go
461 lines
18 KiB
Go
// 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)
|
|
}
|