Federated via graft
10 kB
281 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 RadicleWebURL string // https://explorer/nodes/host/rid, for links in the UI
31 ForgejoBranch string
32 RID string
33 RadHome string
34 State *state.Store
35 Series string // dashboard row this pair's activity groups under
36 SeriesURL string // where clicking that row's name goes
37 Bluesky *BlueskyPoster // nil unless this pair has bluesky configured
38 // AuthorizedIntegrationSource, if set, names another pair's Forgejo
39 // host that already pushes new commits directly into this pair's
40 // Forgejo via Authorized Integrations (see authint.go) — faster than
41 // this pair waiting for the next poll to relay it via Radicle. When
42 // set, Sync skips its own rad -> forgejo push and just observes
43 // whether the content has already arrived, falling back to pushing it
44 // itself only once a real divergence would otherwise be reported.
45 AuthorizedIntegrationSource string
46}
47
48func (g *GitSyncer) run(args ...string) (string, error) {
49 cmd := exec.Command("git", args...)
50 cmd.Dir = g.WorkDir
51 cmd.Env = append(os.Environ(), "RAD_HOME="+g.RadHome)
52 var out bytes.Buffer
53 cmd.Stdout = &out
54 cmd.Stderr = &out
55 err := cmd.Run()
56 if err != nil {
57 return out.String(), fmt.Errorf("git %v: %w: %s", args, err, out.String())
58 }
59 return out.String(), nil
60}
61
62// ensureClone creates the working clone and its two remotes if this is the
63// first run for this repo pair. The rad remote mirrors exactly what
64// `rad init` itself sets up (verified against a live repo): a fetch URL of
65// rad://<rid>, and a separate push URL of rad://<rid>/<local-node-id> —
66// git-remote-rad needs the pushing node's own id in the push URL.
67func (g *GitSyncer) ensureClone() error {
68 if _, err := os.Stat(filepath.Join(g.WorkDir, ".git")); err == nil {
69 return nil
70 }
71 if err := os.MkdirAll(g.WorkDir, 0o700); err != nil {
72 return fmt.Errorf("create work dir: %w", err)
73 }
74 if _, err := g.run("init", "-q", "-b", g.ForgejoBranch); err != nil {
75 return err
76 }
77 if _, err := g.run("remote", "add", "forgejo", g.ForgejoURL); err != nil {
78 return err
79 }
80
81 ridBare := strings.TrimPrefix(g.RID, "rad:")
82 nid, err := g.localNodeID()
83 if err != nil {
84 return fmt.Errorf("resolve local node id: %w", err)
85 }
86 if _, err := g.run("remote", "add", "rad", "rad://"+ridBare); err != nil {
87 return err
88 }
89 if _, err := g.run("remote", "set-url", "--push", "rad", "rad://"+ridBare+"/"+nid); err != nil {
90 return err
91 }
92 return nil
93}
94
95// localNodeID returns the Radicle node id (DID, without the did:key:
96// prefix) that owns g.RadHome.
97func (g *GitSyncer) localNodeID() (string, error) {
98 // --nid is deprecated in favor of `rad node status --only nid` and
99 // prints a warning to stderr; keep that separate from stdout so it
100 // never ends up concatenated into the id itself.
101 cmd := exec.Command("rad", "self", "--nid")
102 cmd.Env = append(os.Environ(), "RAD_HOME="+g.RadHome)
103 var out, errOut bytes.Buffer
104 cmd.Stdout = &out
105 cmd.Stderr = &errOut
106 if err := cmd.Run(); err != nil {
107 return "", fmt.Errorf("rad self --nid: %w: %s", err, errOut.String())
108 }
109 return strings.TrimSpace(out.String()), nil
110}
111
112// headOf returns the commit SHA a remote branch currently points to, or ""
113// if the branch does not exist yet on that remote.
114func (g *GitSyncer) headOf(remote, branch string) (string, error) {
115 out, err := g.run("ls-remote", remote, "refs/heads/"+branch)
116 if err != nil {
117 return "", err
118 }
119 if out == "" {
120 return "", nil
121 }
122 var sha string
123 fmt.Sscanf(out, "%s", &sha)
124 return sha, nil
125}
126
127// isAncestor reports whether commit a is an ancestor of commit b (i.e.
128// fast-forwarding from a to b is safe).
129func (g *GitSyncer) isAncestor(a, b string) bool {
130 _, err := g.run("merge-base", "--is-ancestor", a, b)
131 return err == nil
132}
133
134// Sync mirrors the default branch in whichever direction has new,
135// fast-forwardable commits. It never force-pushes: a genuine divergence
136// (commits on both sides since the last sync, neither an ancestor of the
137// other) is reported as an error so the operator can resolve it by hand.
138func (g *GitSyncer) Sync() error {
139 if err := g.ensureClone(); err != nil {
140 return err
141 }
142
143 forgejoHead, err := g.headOf("forgejo", g.ForgejoBranch)
144 if err != nil {
145 return fmt.Errorf("read forgejo head: %w", err)
146 }
147 radHead, err := g.headOf("rad", g.ForgejoBranch)
148 if err != nil {
149 return fmt.Errorf("read rad head: %w", err)
150 }
151
152 lastForgejo, lastRad, err := g.State.GitCursor(g.RepoPair)
153 if err != nil {
154 return fmt.Errorf("read cursor: %w", err)
155 }
156
157 switch {
158 case forgejoHead == radHead:
159 // Already in sync; still record the cursor in case this is the
160 // first run and it was empty before.
161
162 case radHead == "" || radHead == lastRad:
163 // Radicle hasn't moved since we last looked: forgejo is ahead.
164 if err := g.pushBranch("forgejo", "rad", radHead, state.ForgejoToRadicle); err != nil {
165 return fmt.Errorf("mirror forgejo -> rad: %w", err)
166 }
167
168 case forgejoHead == "" || forgejoHead == lastForgejo:
169 // Forgejo hasn't moved: rad is ahead.
170 if g.AuthorizedIntegrationSource != "" {
171 // Someone else's CI already pushes directly into this
172 // Forgejo (see AuthorizedIntegrationSource's doc comment) —
173 // faster than waiting for us to relay it via Radicle.
174 // Nothing to do; next pass sees forgejoHead == radHead once
175 // it arrives, same as any other convergence.
176 break
177 }
178 if err := g.pushBranch("rad", "forgejo", forgejoHead, state.RadicleToForgejo); err != nil {
179 return fmt.Errorf("mirror rad -> forgejo: %w", err)
180 }
181
182 case g.isAncestor(lastForgejo, forgejoHead) && g.isAncestor(lastRad, radHead) &&
183 g.isAncestor(radHead, forgejoHead):
184 // Both moved, but rad's tip is contained in forgejo's: forgejo wins.
185 if err := g.pushBranch("forgejo", "rad", radHead, state.ForgejoToRadicle); err != nil {
186 return fmt.Errorf("mirror forgejo -> rad: %w", err)
187 }
188
189 case g.isAncestor(forgejoHead, radHead):
190 // Symmetric case: forgejo's tip is contained in rad's.
191 if g.AuthorizedIntegrationSource != "" {
192 break
193 }
194 if err := g.pushBranch("rad", "forgejo", forgejoHead, state.RadicleToForgejo); err != nil {
195 return fmt.Errorf("mirror rad -> forgejo: %w", err)
196 }
197
198 default:
199 return fmt.Errorf(
200 "%s: forgejo and rad diverged since last sync (forgejo=%s rad=%s, last forgejo=%s rad=%s); resolve manually, not auto-merging",
201 g.RepoPair, forgejoHead, radHead, lastForgejo, lastRad)
202 }
203
204 forgejoHead, err = g.headOf("forgejo", g.ForgejoBranch)
205 if err != nil {
206 return err
207 }
208 radHead, err = g.headOf("rad", g.ForgejoBranch)
209 if err != nil {
210 return err
211 }
212 return g.State.SetGitCursor(g.RepoPair, forgejoHead, radHead)
213}
214
215// pushBranch fetches g.ForgejoBranch from "from" and pushes it to "to".
216// oldToHead is that branch's head on "to" before the push (possibly ""),
217// used only to log which commits this pass actually moved, for the status
218// page's activity calendar.
219func (g *GitSyncer) pushBranch(from, to, oldToHead, direction string) error {
220 if _, err := g.run("fetch", "-q", from, g.ForgejoBranch); err != nil {
221 return fmt.Errorf("fetch %s: %w", from, err)
222 }
223 if _, err := g.run("push", to, "FETCH_HEAD:refs/heads/"+g.ForgejoBranch); err != nil {
224 return fmt.Errorf("push %s: %w", to, err)
225 }
226 g.logMirroredCommits(oldToHead, direction, to)
227 return nil
228}
229
230// logMirroredCommits records one activity_log entry per commit newly
231// reachable from FETCH_HEAD that wasn't reachable from oldHead. Best-effort:
232// a failure here never fails the sync itself, it just leaves the status
233// page's calendar short an entry. to is "forgejo" or "rad" — the remote the
234// commits were just pushed to — so the logged link (and the dashboard's
235// derived server label) points at where the content actually landed,
236// instead of always pointing at Forgejo regardless of direction.
237func (g *GitSyncer) logMirroredCommits(oldHead, direction, to string) {
238 rangeSpec := "FETCH_HEAD"
239 if oldHead != "" {
240 rangeSpec = oldHead + "..FETCH_HEAD"
241 }
242 // %x1f (unit separator) between fields: commit subjects can contain
243 // anything else printable, so a space or comma delimiter would be
244 // ambiguous to split back apart.
245 out, err := g.run("log", "--pretty=format:%H%x1f%h %s", rangeSpec)
246 if err != nil || out == "" {
247 return
248 }
249 for _, line := range strings.Split(out, "\n") {
250 if line == "" {
251 continue
252 }
253 full, summary, ok := strings.Cut(line, "\x1f")
254 if !ok {
255 continue
256 }
257 url := ""
258 switch to {
259 case "rad":
260 if g.RadicleWebURL != "" {
261 url = g.RadicleWebURL + "/commits/" + full
262 }
263 default:
264 if g.ForgejoWebURL != "" {
265 url = g.ForgejoWebURL + "/commit/" + full
266 }
267 }
268 g.State.LogActivity(state.Activity{
269 RepoPair: g.RepoPair, Series: g.Series, SeriesURL: g.SeriesURL,
270 Kind: "git", Direction: direction, Summary: summary, URL: url,
271 })
272 // Best-effort, same as LogActivity above: a Bluesky hiccup never
273 // fails the sync, and nil Bluesky (the common case, no account
274 // configured) is a silent no-op.
275 text := g.Series + ": " + summary
276 if url != "" {
277 text += "\n" + url
278 }
279 g.Bluesky.Post(text)
280 }
281}