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