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