Federated via graft
0

Configure Feed

Select the types of activity you want to include in your feed.

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}