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 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}