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