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) }