323 lines
8.2 KiB
Go
323 lines
8.2 KiB
Go
package syncer
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"syncbot/internal/config"
|
|
"syncbot/internal/gitx"
|
|
)
|
|
|
|
// harness wires up a real src repo, a real bare dst repo and a Syncer, so the
|
|
// tests exercise the actual git plumbing rather than a mock of it.
|
|
type harness struct {
|
|
t *testing.T
|
|
src string
|
|
dst string
|
|
job config.Job
|
|
sync *Syncer
|
|
}
|
|
|
|
func newHarness(t *testing.T) *harness {
|
|
t.Helper()
|
|
if _, err := exec.LookPath("git"); err != nil {
|
|
t.Skip("git not installed")
|
|
}
|
|
|
|
root := t.TempDir()
|
|
src := filepath.Join(root, "src")
|
|
dst := filepath.Join(root, "dst.git")
|
|
home := filepath.Join(root, "home")
|
|
for _, d := range []string{src, dst, home} {
|
|
if err := os.MkdirAll(d, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
git(t, src, "init", "--quiet", "-b", "main")
|
|
git(t, dst, "init", "--bare", "--quiet", "-b", "main")
|
|
// A bare repo refuses to have the branch its HEAD points at deleted, which
|
|
// would otherwise make the allow_empty case untestable. GitHub behaves the
|
|
// same way for its default branch; see the README's troubleshooting notes.
|
|
git(t, dst, "config", "receive.denyDeleteCurrent", "ignore")
|
|
|
|
h := &harness{
|
|
t: t,
|
|
src: src,
|
|
dst: dst,
|
|
job: config.Job{
|
|
Name: "test",
|
|
Src: config.Endpoint{URL: src},
|
|
Dst: config.Endpoint{URL: dst},
|
|
Dir: filepath.Join(root, "mirror.git"),
|
|
Interval: time.Minute,
|
|
Timeout: 2 * time.Minute,
|
|
MaxBackoff: time.Minute,
|
|
Refs: config.DefaultRefs,
|
|
Prune: true,
|
|
Force: true,
|
|
},
|
|
sync: &Syncer{Log: slog.New(slog.NewTextHandler(io.Discard, nil)), Home: home},
|
|
}
|
|
h.commit("first")
|
|
return h
|
|
}
|
|
|
|
func (h *harness) commit(msg string) {
|
|
h.t.Helper()
|
|
path := filepath.Join(h.src, "file.txt")
|
|
if err := os.WriteFile(path, []byte(msg+"\n"), 0o600); err != nil {
|
|
h.t.Fatal(err)
|
|
}
|
|
git(h.t, h.src, "add", "-A")
|
|
git(h.t, h.src, "commit", "--quiet", "-m", msg)
|
|
}
|
|
|
|
func (h *harness) run() Result {
|
|
h.t.Helper()
|
|
res, err := h.sync.Sync(context.Background(), h.job)
|
|
if err != nil {
|
|
h.t.Fatalf("sync: %v", err)
|
|
}
|
|
return res
|
|
}
|
|
|
|
func (h *harness) refs(dir string) gitx.Refs {
|
|
h.t.Helper()
|
|
out := git(h.t, dir, "for-each-ref", "--format=%(objectname)\t%(refname)")
|
|
refs := gitx.Refs{}
|
|
for _, line := range strings.Split(out, "\n") {
|
|
if obj, ref, ok := strings.Cut(strings.TrimSpace(line), "\t"); ok {
|
|
refs[ref] = obj
|
|
}
|
|
}
|
|
return refs
|
|
}
|
|
|
|
// assertMirrored checks that dst holds exactly what src holds.
|
|
func (h *harness) assertMirrored() {
|
|
h.t.Helper()
|
|
src, dst := h.refs(h.src), h.refs(h.dst)
|
|
if !src.Equal(dst) {
|
|
h.t.Fatalf("dst does not mirror src\n src: %v\n dst: %v", src, dst)
|
|
}
|
|
}
|
|
|
|
func TestFirstSyncCopiesEverything(t *testing.T) {
|
|
h := newHarness(t)
|
|
git(t, h.src, "tag", "-a", "v1.0.0", "-m", "release")
|
|
git(t, h.src, "branch", "feature/x")
|
|
|
|
res := h.run()
|
|
if !res.Fetched || !res.Pushed {
|
|
t.Fatalf("want fetch and push on first sync, got %+v", res)
|
|
}
|
|
h.assertMirrored()
|
|
|
|
dst := h.refs(h.dst)
|
|
for _, want := range []string{"refs/heads/main", "refs/heads/feature/x", "refs/tags/v1.0.0"} {
|
|
if _, ok := dst[want]; !ok {
|
|
t.Errorf("dst missing %s (has %v)", want, dst)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNoChangesDoesNothing(t *testing.T) {
|
|
h := newHarness(t)
|
|
h.run()
|
|
|
|
res := h.run()
|
|
if res.Fetched {
|
|
t.Error("fetched despite src being unchanged")
|
|
}
|
|
if res.Pushed {
|
|
t.Error("pushed despite dst already being in sync")
|
|
}
|
|
}
|
|
|
|
func TestNewCommitPropagates(t *testing.T) {
|
|
h := newHarness(t)
|
|
h.run()
|
|
|
|
h.commit("second")
|
|
res := h.run()
|
|
if !res.Fetched || !res.Pushed {
|
|
t.Fatalf("want fetch and push after a new commit, got %+v", res)
|
|
}
|
|
h.assertMirrored()
|
|
}
|
|
|
|
func TestDeletedBranchIsPruned(t *testing.T) {
|
|
h := newHarness(t)
|
|
git(t, h.src, "branch", "temp")
|
|
h.run()
|
|
if _, ok := h.refs(h.dst)["refs/heads/temp"]; !ok {
|
|
t.Fatal("setup: dst should have refs/heads/temp")
|
|
}
|
|
|
|
git(t, h.src, "branch", "-D", "temp")
|
|
h.run()
|
|
|
|
if _, ok := h.refs(h.dst)["refs/heads/temp"]; ok {
|
|
t.Error("refs/heads/temp still on dst after being deleted upstream")
|
|
}
|
|
h.assertMirrored()
|
|
}
|
|
|
|
func TestForcePushAfterRewrite(t *testing.T) {
|
|
h := newHarness(t)
|
|
h.commit("second")
|
|
h.run()
|
|
|
|
// Rewrite history the way a rebase or an amended commit would.
|
|
git(t, h.src, "reset", "--hard", "--quiet", "HEAD~1")
|
|
h.commit("rewritten")
|
|
res := h.run()
|
|
|
|
if !res.Pushed {
|
|
t.Fatal("want a push after history was rewritten")
|
|
}
|
|
h.assertMirrored()
|
|
}
|
|
|
|
// The bot keeps no state between runs, so damage done directly to dst must heal
|
|
// on the next cycle even though src has not moved.
|
|
func TestDestinationDriftIsRepaired(t *testing.T) {
|
|
h := newHarness(t)
|
|
git(t, h.src, "branch", "keep")
|
|
h.run()
|
|
|
|
git(t, h.dst, "update-ref", "-d", "refs/heads/keep")
|
|
if _, ok := h.refs(h.dst)["refs/heads/keep"]; ok {
|
|
t.Fatal("setup: refs/heads/keep should be gone from dst")
|
|
}
|
|
|
|
res := h.run()
|
|
if res.Fetched {
|
|
t.Error("fetched even though src had not changed")
|
|
}
|
|
if !res.Pushed {
|
|
t.Fatal("want a push to repair dst")
|
|
}
|
|
h.assertMirrored()
|
|
}
|
|
|
|
func TestEmptySourceIsRefused(t *testing.T) {
|
|
h := newHarness(t)
|
|
h.run()
|
|
|
|
// Simulate a source that answers but has nothing to offer — a revoked
|
|
// token or a wrong URL looks exactly like this.
|
|
empty := filepath.Join(t.TempDir(), "empty.git")
|
|
git(t, t.TempDir(), "init", "--bare", "--quiet", empty)
|
|
h.job.Src = config.Endpoint{URL: empty}
|
|
|
|
_, err := h.sync.Sync(context.Background(), h.job)
|
|
if err == nil {
|
|
t.Fatal("want an error when an empty source would wipe dst")
|
|
}
|
|
if !strings.Contains(err.Error(), "allow_empty") {
|
|
t.Errorf("error should point at allow_empty, got: %v", err)
|
|
}
|
|
if len(h.refs(h.dst)) == 0 {
|
|
t.Error("dst was wiped despite the guard")
|
|
}
|
|
|
|
// With the guard lifted the wipe goes through, as documented.
|
|
h.job.AllowEmpty = true
|
|
if _, err := h.sync.Sync(context.Background(), h.job); err != nil {
|
|
t.Fatalf("sync with allow_empty: %v", err)
|
|
}
|
|
if n := len(h.refs(h.dst)); n != 0 {
|
|
t.Errorf("dst should be empty with allow_empty = true, has %d refs", n)
|
|
}
|
|
}
|
|
|
|
func TestRefsFilterLimitsWhatIsMirrored(t *testing.T) {
|
|
h := newHarness(t)
|
|
git(t, h.src, "tag", "v1")
|
|
h.job.Refs = []string{"refs/heads/*"}
|
|
|
|
h.run()
|
|
|
|
dst := h.refs(h.dst)
|
|
if _, ok := dst["refs/heads/main"]; !ok {
|
|
t.Error("branches should be mirrored")
|
|
}
|
|
if _, ok := dst["refs/tags/v1"]; ok {
|
|
t.Error("tags should not be mirrored when refs excludes them")
|
|
}
|
|
}
|
|
|
|
func TestUnreachableSourceReportsError(t *testing.T) {
|
|
h := newHarness(t)
|
|
h.job.Src = config.Endpoint{URL: filepath.Join(t.TempDir(), "does-not-exist.git")}
|
|
h.job.Timeout = 30 * time.Second
|
|
|
|
if _, err := h.sync.Sync(context.Background(), h.job); err == nil {
|
|
t.Fatal("want an error for an unreachable source")
|
|
}
|
|
}
|
|
|
|
func TestTimeoutIsEnforced(t *testing.T) {
|
|
h := newHarness(t)
|
|
h.job.Timeout = time.Nanosecond
|
|
|
|
_, err := h.sync.Sync(context.Background(), h.job)
|
|
if err == nil {
|
|
t.Fatal("want an error when the timeout expires")
|
|
}
|
|
}
|
|
|
|
func TestNeedsPush(t *testing.T) {
|
|
local := gitx.Refs{"refs/heads/main": "aaa"}
|
|
|
|
cases := []struct {
|
|
name string
|
|
local gitx.Refs
|
|
dst gitx.Refs
|
|
prune bool
|
|
want bool
|
|
}{
|
|
{"identical", local, gitx.Refs{"refs/heads/main": "aaa"}, true, false},
|
|
{"moved", local, gitx.Refs{"refs/heads/main": "bbb"}, true, true},
|
|
{"missing on dst", local, gitx.Refs{}, true, true},
|
|
{"extra on dst, pruning", local, gitx.Refs{"refs/heads/main": "aaa", "refs/heads/x": "c"}, true, true},
|
|
{"extra on dst, not pruning", local, gitx.Refs{"refs/heads/main": "aaa", "refs/heads/x": "c"}, false, false},
|
|
{"both empty", gitx.Refs{}, gitx.Refs{}, true, false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := needsPush(tc.local, tc.dst, tc.prune); got != tc.want {
|
|
t.Errorf("needsPush = %v, want %v", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func git(t *testing.T, dir string, args ...string) string {
|
|
t.Helper()
|
|
cmd := exec.Command("git", args...)
|
|
cmd.Dir = dir
|
|
cmd.Env = append(os.Environ(),
|
|
"GIT_CONFIG_GLOBAL="+os.DevNull,
|
|
"GIT_CONFIG_SYSTEM="+os.DevNull,
|
|
"GIT_AUTHOR_NAME=syncbot test",
|
|
"GIT_AUTHOR_EMAIL=test@example.invalid",
|
|
"GIT_COMMITTER_NAME=syncbot test",
|
|
"GIT_COMMITTER_EMAIL=test@example.invalid",
|
|
)
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
|
|
}
|
|
return string(out)
|
|
}
|