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) 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"); 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"); 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"); 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"); 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 191func (g *GitSyncer) pushBranch(from, to string) error { 192 if _, err := g.run("fetch", "-q", from, g.ForgejoBranch); err != nil { 193 return fmt.Errorf("fetch %s: %w", from, err) 194 } 195 if _, err := g.run("push", to, "FETCH_HEAD:refs/heads/"+g.ForgejoBranch); err != nil { 196 return fmt.Errorf("push %s: %w", to, err) 197 } 198 return nil 199}