Federated via graft
8.1 kB
237 lines
1// Package sync is the actual synchronization brick: it takes a Forgejo
2// client and a Radicle client for one repo pair and mirrors content between
3// them, using the state store to stay idempotent across runs.
4//
5// Scope of this first version, deliberately: fast-forward-only git mirroring
6// (a real divergence is logged and skipped, never force-pushed), and
7// create-only issue/patch mirroring (edits made after the initial mirror are
8// not propagated — that needs per-item diffing, left for a follow-up once
9// the create path is proven in practice).
10package sync
11
12import (
13 "bytes"
14 "fmt"
15 "os"
16 "os/exec"
17 "path/filepath"
18 "strings"
19
20 "graft/internal/state"
21)
22
23// GitSyncer mirrors a repository's default branch between Forgejo and
24// Radicle using a local working clone with two remotes.
25type GitSyncer struct {
26 RepoPair string // key used in the state store
27 WorkDir string // local clone, created on first run if absent
28 ForgejoURL string // authenticated clone URL (token embedded), for git
29 ForgejoWebURL string // https://host/owner/repo, for links in the UI
30 ForgejoBranch string
31 RID string
32 RadHome string
33 State *state.Store
34}
35
36func (g *GitSyncer) run(args ...string) (string, error) {
37 cmd := exec.Command("git", args...)
38 cmd.Dir = g.WorkDir
39 cmd.Env = append(os.Environ(), "RAD_HOME="+g.RadHome)
40 var out bytes.Buffer
41 cmd.Stdout = &out
42 cmd.Stderr = &out
43 err := cmd.Run()
44 if err != nil {
45 return out.String(), fmt.Errorf("git %v: %w: %s", args, err, out.String())
46 }
47 return out.String(), nil
48}
49
50// ensureClone creates the working clone and its two remotes if this is the
51// first run for this repo pair. The rad remote mirrors exactly what
52// `rad init` itself sets up (verified against a live repo): a fetch URL of
53// rad://<rid>, and a separate push URL of rad://<rid>/<local-node-id> —
54// git-remote-rad needs the pushing node's own id in the push URL.
55func (g *GitSyncer) ensureClone() error {
56 if _, err := os.Stat(filepath.Join(g.WorkDir, ".git")); err == nil {
57 return nil
58 }
59 if err := os.MkdirAll(g.WorkDir, 0o700); err != nil {
60 return fmt.Errorf("create work dir: %w", err)
61 }
62 if _, err := g.run("init", "-q", "-b", g.ForgejoBranch); err != nil {
63 return err
64 }
65 if _, err := g.run("remote", "add", "forgejo", g.ForgejoURL); err != nil {
66 return err
67 }
68
69 ridBare := strings.TrimPrefix(g.RID, "rad:")
70 nid, err := g.localNodeID()
71 if err != nil {
72 return fmt.Errorf("resolve local node id: %w", err)
73 }
74 if _, err := g.run("remote", "add", "rad", "rad://"+ridBare); err != nil {
75 return err
76 }
77 if _, err := g.run("remote", "set-url", "--push", "rad", "rad://"+ridBare+"/"+nid); err != nil {
78 return err
79 }
80 return nil
81}
82
83// localNodeID returns the Radicle node id (DID, without the did:key:
84// prefix) that owns g.RadHome.
85func (g *GitSyncer) localNodeID() (string, error) {
86 // --nid is deprecated in favor of `rad node status --only nid` and
87 // prints a warning to stderr; keep that separate from stdout so it
88 // never ends up concatenated into the id itself.
89 cmd := exec.Command("rad", "self", "--nid")
90 cmd.Env = append(os.Environ(), "RAD_HOME="+g.RadHome)
91 var out, errOut bytes.Buffer
92 cmd.Stdout = &out
93 cmd.Stderr = &errOut
94 if err := cmd.Run(); err != nil {
95 return "", fmt.Errorf("rad self --nid: %w: %s", err, errOut.String())
96 }
97 return strings.TrimSpace(out.String()), nil
98}
99
100// headOf returns the commit SHA a remote branch currently points to, or ""
101// if the branch does not exist yet on that remote.
102func (g *GitSyncer) headOf(remote, branch string) (string, error) {
103 out, err := g.run("ls-remote", remote, "refs/heads/"+branch)
104 if err != nil {
105 return "", err
106 }
107 if out == "" {
108 return "", nil
109 }
110 var sha string
111 fmt.Sscanf(out, "%s", &sha)
112 return sha, nil
113}
114
115// isAncestor reports whether commit a is an ancestor of commit b (i.e.
116// fast-forwarding from a to b is safe).
117func (g *GitSyncer) isAncestor(a, b string) bool {
118 _, err := g.run("merge-base", "--is-ancestor", a, b)
119 return err == nil
120}
121
122// Sync mirrors the default branch in whichever direction has new,
123// fast-forwardable commits. It never force-pushes: a genuine divergence
124// (commits on both sides since the last sync, neither an ancestor of the
125// other) is reported as an error so the operator can resolve it by hand.
126func (g *GitSyncer) Sync() error {
127 if err := g.ensureClone(); err != nil {
128 return err
129 }
130
131 forgejoHead, err := g.headOf("forgejo", g.ForgejoBranch)
132 if err != nil {
133 return fmt.Errorf("read forgejo head: %w", err)
134 }
135 radHead, err := g.headOf("rad", g.ForgejoBranch)
136 if err != nil {
137 return fmt.Errorf("read rad head: %w", err)
138 }
139
140 lastForgejo, lastRad, err := g.State.GitCursor(g.RepoPair)
141 if err != nil {
142 return fmt.Errorf("read cursor: %w", err)
143 }
144
145 switch {
146 case forgejoHead == radHead:
147 // Already in sync; still record the cursor in case this is the
148 // first run and it was empty before.
149
150 case radHead == "" || radHead == lastRad:
151 // Radicle hasn't moved since we last looked: forgejo is ahead.
152 if err := g.pushBranch("forgejo", "rad", radHead, state.ForgejoToRadicle); err != nil {
153 return fmt.Errorf("mirror forgejo -> rad: %w", err)
154 }
155
156 case forgejoHead == "" || forgejoHead == lastForgejo:
157 // Forgejo hasn't moved: rad is ahead.
158 if err := g.pushBranch("rad", "forgejo", forgejoHead, state.RadicleToForgejo); err != nil {
159 return fmt.Errorf("mirror rad -> forgejo: %w", err)
160 }
161
162 case g.isAncestor(lastForgejo, forgejoHead) && g.isAncestor(lastRad, radHead) &&
163 g.isAncestor(radHead, forgejoHead):
164 // Both moved, but rad's tip is contained in forgejo's: forgejo wins.
165 if err := g.pushBranch("forgejo", "rad", radHead, state.ForgejoToRadicle); err != nil {
166 return fmt.Errorf("mirror forgejo -> rad: %w", err)
167 }
168
169 case g.isAncestor(forgejoHead, radHead):
170 // Symmetric case: forgejo's tip is contained in rad's.
171 if err := g.pushBranch("rad", "forgejo", forgejoHead, state.RadicleToForgejo); err != nil {
172 return fmt.Errorf("mirror rad -> forgejo: %w", err)
173 }
174
175 default:
176 return fmt.Errorf(
177 "%s: forgejo and rad diverged since last sync (forgejo=%s rad=%s, last forgejo=%s rad=%s); resolve manually, not auto-merging",
178 g.RepoPair, forgejoHead, radHead, lastForgejo, lastRad)
179 }
180
181 forgejoHead, err = g.headOf("forgejo", g.ForgejoBranch)
182 if err != nil {
183 return err
184 }
185 radHead, err = g.headOf("rad", g.ForgejoBranch)
186 if err != nil {
187 return err
188 }
189 return g.State.SetGitCursor(g.RepoPair, forgejoHead, radHead)
190}
191
192// pushBranch fetches g.ForgejoBranch from "from" and pushes it to "to".
193// oldToHead is that branch's head on "to" before the push (possibly ""),
194// used only to log which commits this pass actually moved, for the status
195// page's activity calendar.
196func (g *GitSyncer) pushBranch(from, to, oldToHead, direction string) error {
197 if _, err := g.run("fetch", "-q", from, g.ForgejoBranch); err != nil {
198 return fmt.Errorf("fetch %s: %w", from, err)
199 }
200 if _, err := g.run("push", to, "FETCH_HEAD:refs/heads/"+g.ForgejoBranch); err != nil {
201 return fmt.Errorf("push %s: %w", to, err)
202 }
203 g.logMirroredCommits(oldToHead, direction)
204 return nil
205}
206
207// logMirroredCommits records one activity_log entry per commit newly
208// reachable from FETCH_HEAD that wasn't reachable from oldHead. Best-effort:
209// a failure here never fails the sync itself, it just leaves the status
210// page's calendar short an entry.
211func (g *GitSyncer) logMirroredCommits(oldHead, direction string) {
212 rangeSpec := "FETCH_HEAD"
213 if oldHead != "" {
214 rangeSpec = oldHead + "..FETCH_HEAD"
215 }
216 // %x1f (unit separator) between fields: commit subjects can contain
217 // anything else printable, so a space or comma delimiter would be
218 // ambiguous to split back apart.
219 out, err := g.run("log", "--pretty=format:%H%x1f%h %s", rangeSpec)
220 if err != nil || out == "" {
221 return
222 }
223 for _, line := range strings.Split(out, "\n") {
224 if line == "" {
225 continue
226 }
227 full, summary, ok := strings.Cut(line, "\x1f")
228 if !ok {
229 continue
230 }
231 url := ""
232 if g.ForgejoWebURL != "" {
233 url = g.ForgejoWebURL + "/commit/" + full
234 }
235 g.State.LogActivity(g.RepoPair, "git", direction, summary, url)
236 }
237}