init
ci / test (push) Canceled after 0s
docker / build (push) Canceled after 0s

This commit is contained in:
iceBear67
2026-08-14 07:13:22 +00:00
commit a006483bbc
26 changed files with 3826 additions and 0 deletions
+163
View File
@@ -0,0 +1,163 @@
// Package syncer performs a single src -> dst mirror cycle.
//
// The design is deliberately stateless: nothing is remembered between runs.
// Each cycle asks both remotes what they currently hold and does only the work
// needed to make them agree. That makes the bot self-healing — if someone
// force-pushes the destination, or a push half-fails, or the container is
// rebuilt from scratch, the next cycle simply notices and converges.
package syncer
import (
"context"
"fmt"
"log/slog"
"os"
"time"
"syncbot/internal/config"
"syncbot/internal/gitx"
)
// Result summarises what a cycle did, for logging and metrics.
type Result struct {
Fetched bool // objects were pulled from src
Pushed bool // refs were written to dst
Refs int // number of managed refs after the cycle
Changes []string // porcelain lines for the refs that moved
Duration time.Duration
}
// Syncer runs mirror cycles. It holds no per-repo state and is safe for
// concurrent use.
type Syncer struct {
Log *slog.Logger
Home string // isolated HOME for git/ssh; must exist and be writable
}
// Sync brings job.Dst in line with job.Src.
func (s *Syncer) Sync(ctx context.Context, job config.Job) (Result, error) {
start := time.Now()
var res Result
ctx, cancel := context.WithTimeout(ctx, job.Timeout)
defer cancel()
// Staged deploy-key copies live here and are shredded when we are done.
tmp, err := os.MkdirTemp(s.Home, "ssh-")
if err != nil {
return res, fmt.Errorf("create temp dir: %w", err)
}
defer os.RemoveAll(tmp)
log := s.Log.With("repo", job.Name)
secrets := []string{gitx.URLSecret(job.Src.URL), gitx.URLSecret(job.Dst.URL)}
srcOpts, err := s.options(job.Src, job.GitConfig, tmp, secrets, log)
if err != nil {
return res, fmt.Errorf("src: %w", err)
}
dstOpts, err := s.options(job.Dst, job.GitConfig, tmp, secrets, log)
if err != nil {
return res, fmt.Errorf("dst: %w", err)
}
if err := gitx.EnsureMirror(ctx, srcOpts, job.Dir); err != nil {
return res, err
}
// 1. Ask src what it has. This is the cheap poll that runs every interval.
srcRefs, err := gitx.LsRemote(ctx, srcOpts, job.Dir, job.Src.URL, job.Refs)
if err != nil {
return res, fmt.Errorf("read src %s: %w", gitx.RedactURL(job.Src.URL), err)
}
localRefs, err := gitx.LocalRefs(ctx, srcOpts, job.Dir, job.Refs)
if err != nil {
return res, fmt.Errorf("read mirror: %w", err)
}
// 2. Only transfer objects when the mirror is actually behind.
if !srcRefs.Equal(localRefs) {
log.Info("fetching", "src", gitx.RedactURL(job.Src.URL),
"local_refs", len(localRefs), "src_refs", len(srcRefs))
if err := gitx.Fetch(ctx, srcOpts, job.Dir, job.Src.URL, job.Refs); err != nil {
return res, fmt.Errorf("fetch from %s: %w", gitx.RedactURL(job.Src.URL), err)
}
res.Fetched = true
if localRefs, err = gitx.LocalRefs(ctx, srcOpts, job.Dir, job.Refs); err != nil {
return res, fmt.Errorf("read mirror after fetch: %w", err)
}
}
res.Refs = len(localRefs)
// 3. Ask dst what it has, so external drift is detected too.
dstRefs, err := gitx.LsRemote(ctx, dstOpts, job.Dir, job.Dst.URL, job.Refs)
if err != nil {
return res, fmt.Errorf("read dst %s: %w", gitx.RedactURL(job.Dst.URL), err)
}
if !needsPush(localRefs, dstRefs, job.Prune) {
res.Duration = time.Since(start)
log.Debug("already in sync", "refs", res.Refs, "dur", res.Duration.Round(time.Millisecond))
return res, nil
}
// Safety net: an upstream that suddenly reports zero refs is far more
// likely to be a broken URL or a revoked token than a genuine wipe, and
// pushing that through with --prune would delete the destination.
if len(localRefs) == 0 && len(dstRefs) > 0 && !job.AllowEmpty {
return res, fmt.Errorf("refusing to mirror an empty source over %d ref(s) on dst; "+
"set allow_empty = true if this is intended", len(dstRefs))
}
log.Info("pushing", "dst", gitx.RedactURL(job.Dst.URL), "refs", res.Refs)
changes, err := gitx.Push(ctx, dstOpts, job.Dir, job.Dst.URL, job.Refs, gitx.PushOptions{
Prune: job.Prune,
Force: job.Force,
Atomic: job.Atomic,
})
if err != nil {
return res, fmt.Errorf("push to %s: %w", gitx.RedactURL(job.Dst.URL), err)
}
res.Pushed = true
res.Changes = changes
res.Duration = time.Since(start)
return res, nil
}
// options builds the git invocation environment for one endpoint.
func (s *Syncer) options(e config.Endpoint, gitConfig []string, tmp string, secrets []string, log *slog.Logger) (gitx.Options, error) {
o := gitx.Options{
Log: log,
Home: s.Home,
GitConfig: gitConfig,
Secrets: secrets,
}
sshCmd, err := gitx.PrepareSSH(gitx.SSH{
KeyPath: e.SSHKey,
KnownHosts: e.KnownHosts,
StrictHostKey: e.StrictHostKey,
}, s.Home, tmp)
if err != nil {
return o, err
}
o.SSHCmd = sshCmd
return o, nil
}
// needsPush reports whether dst differs from the mirror in any way we manage.
func needsPush(local, dst gitx.Refs, prune bool) bool {
for ref, obj := range local {
if dst[ref] != obj {
return true
}
}
if prune {
for ref := range dst {
if _, ok := local[ref]; !ok {
return true
}
}
}
return false
}
+322
View File
@@ -0,0 +1,322 @@
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)
}