Federated via graft
0

Configure Feed

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

Support Forgejo-to-Forgejo git mirroring without a Radicle side

RepoPair.Radicle becomes optional (*RadicleTarget); a pair can instead
set forgejo_mirror to mirror its default branch, fast-forward-only,
against a second Forgejo instance with no Radicle node involved at
all. Issues/patches sync stays Radicle-only for now (that logic is
written entirely in terms of a Radicle client) — validate() rejects
sync.issues/sync.patches on a forgejo_mirror pair.

New internal/sync/git_ff.go (GitSyncerFF) mirrors GitSyncer's
fast-forward/divergence logic between two Forgejo remotes ("a"/"b")
instead of "forgejo"/"rad". New Direction constants
ForgejoAToForgejoB/ForgejoBToForgejoA for its activity log entries.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KB7C5CeuMbQujqRtb4foki

+385 -33
+26 -12
cmd/sync/main.go
··· 58 58 } 59 59 } 60 60 defaultRadHome := "" 61 - if len(cfg.Repos) > 0 { 62 - defaultRadHome = cfg.Repos[0].Radicle.RadHome 61 + for _, p := range cfg.Repos { 62 + if p.Radicle != nil { 63 + defaultRadHome = p.Radicle.RadHome 64 + break 65 + } 63 66 } 64 67 65 68 // live holds every piece of runtime state a dynamically-added peer or ··· 300 303 series = p.Name 301 304 } 302 305 if _, ok := seen[series]; !ok { 303 - seen[series] = admin.SeriesInfo{ 304 - Name: series, 305 - RadicleRID: p.Radicle.RID, 306 - RadicleHTTPBaseURL: p.Radicle.HTTPBaseURL, 307 - RadicleExplorerURL: p.Radicle.ExplorerURL, 306 + info := admin.SeriesInfo{Name: series} 307 + if p.Radicle != nil { 308 + info.RadicleRID = p.Radicle.RID 309 + info.RadicleHTTPBaseURL = p.Radicle.HTTPBaseURL 310 + info.RadicleExplorerURL = p.Radicle.ExplorerURL 308 311 } 312 + seen[series] = info 309 313 order = append(order, series) 310 314 } 311 315 } ··· 324 328 Forgejo: config.ForgejoTarget{ 325 329 BaseURL: d.ForgejoBaseURL, Owner: d.ForgejoOwner, Repo: d.ForgejoRepo, TokenFile: d.ForgejoTokenFile, 326 330 }, 327 - Radicle: config.RadicleTarget{ 331 + Radicle: &config.RadicleTarget{ 328 332 RID: d.RadicleRID, HTTPBaseURL: d.RadicleHTTPBaseURL, RadHome: d.RadicleRadHome, ExplorerURL: d.RadicleExplorerURL, 329 333 }, 330 334 Sync: config.SyncScope{Git: d.SyncGit, Issues: d.SyncIssues, Patches: d.SyncPatches}, ··· 445 449 fURL := strings.TrimRight(pair.Forgejo.BaseURL, "/") + "/" + pair.Forgejo.Owner + "/" + pair.Forgejo.Repo 446 450 add(series, fHost, fURL, pair.Name, false, aiTargetHosts[series][fHost]) 447 451 448 - rHost := "" 449 - if u, err := url.Parse(pair.Radicle.HTTPBaseURL); err == nil { 450 - rHost = u.Host 452 + if pair.Radicle != nil { 453 + rHost := "" 454 + if u, err := url.Parse(pair.Radicle.HTTPBaseURL); err == nil { 455 + rHost = u.Host 456 + } 457 + add(series, rHost, gsync.RadicleExplorerLink(*pair.Radicle), pair.Name, true, false) 451 458 } 452 - add(series, rHost, gsync.RadicleExplorerLink(pair.Radicle), pair.Name, true, false) 459 + if pair.ForgejoMirror != nil { 460 + mHost := "" 461 + if u, err := url.Parse(pair.ForgejoMirror.BaseURL); err == nil { 462 + mHost = u.Host 463 + } 464 + mURL := strings.TrimRight(pair.ForgejoMirror.BaseURL, "/") + "/" + pair.ForgejoMirror.Owner + "/" + pair.ForgejoMirror.Repo 465 + add(series, mHost, mURL, pair.Name, false, aiTargetHosts[series][mHost]) 466 + } 453 467 } 454 468 return topology 455 469 }
+38 -12
internal/config/config.go
··· 28 28 AdminPassword string `yaml:"admin_password"` 29 29 } 30 30 31 - // RepoPair links one Forgejo repository to one Radicle repository and 32 - // declares which content types are kept in sync between them. 31 + // RepoPair links one Forgejo repository to either one Radicle repository or 32 + // a second Forgejo repository, and declares which content types are kept in 33 + // sync between them. Exactly one of Radicle and ForgejoMirror is set: the 34 + // Radicle case is the original, fully-featured pairing (git, issues, 35 + // patches); the ForgejoMirror case is a plain Forgejo-to-Forgejo git mirror 36 + // for series that have no Radicle side at all — issues and patches aren't 37 + // supported there yet, since that mirroring logic is written entirely in 38 + // terms of a Radicle client (see internal/sync/issues.go, patches.go). 33 39 type RepoPair struct { 34 - Name string `yaml:"name"` 35 - Forgejo ForgejoTarget `yaml:"forgejo"` 36 - Radicle RadicleTarget `yaml:"radicle"` 37 - Sync SyncScope `yaml:"sync"` 40 + Name string `yaml:"name"` 41 + Forgejo ForgejoTarget `yaml:"forgejo"` 42 + Radicle *RadicleTarget `yaml:"radicle"` 43 + ForgejoMirror *ForgejoTarget `yaml:"forgejo_mirror"` 44 + Sync SyncScope `yaml:"sync"` 38 45 // Series groups this pair with others that mirror the same underlying 39 46 // repository (one per forge/Radicle side) under one row in the status 40 47 // dashboard. Defaults to Name — i.e. its own row — if unset. ··· 134 141 if r.Forgejo.TokenFile == "" { 135 142 return fmt.Errorf("forgejo.token_file is required") 136 143 } 137 - if r.Radicle.RID == "" { 138 - return fmt.Errorf("radicle.rid is required") 144 + if r.Radicle == nil && r.ForgejoMirror == nil { 145 + return fmt.Errorf("exactly one of radicle or forgejo_mirror is required") 139 146 } 140 - if r.Radicle.HTTPBaseURL == "" { 141 - return fmt.Errorf("radicle.http_base_url is required") 147 + if r.Radicle != nil && r.ForgejoMirror != nil { 148 + return fmt.Errorf("radicle and forgejo_mirror are mutually exclusive") 149 + } 150 + if r.Radicle != nil { 151 + if r.Radicle.RID == "" { 152 + return fmt.Errorf("radicle.rid is required") 153 + } 154 + if r.Radicle.HTTPBaseURL == "" { 155 + return fmt.Errorf("radicle.http_base_url is required") 156 + } 157 + if r.Radicle.RadHome == "" { 158 + return fmt.Errorf("radicle.rad_home is required") 159 + } 142 160 } 143 - if r.Radicle.RadHome == "" { 144 - return fmt.Errorf("radicle.rad_home is required") 161 + if r.ForgejoMirror != nil { 162 + if r.ForgejoMirror.BaseURL == "" || r.ForgejoMirror.Owner == "" || r.ForgejoMirror.Repo == "" { 163 + return fmt.Errorf("forgejo_mirror.base_url, owner and repo are required") 164 + } 165 + if r.ForgejoMirror.TokenFile == "" { 166 + return fmt.Errorf("forgejo_mirror.token_file is required") 167 + } 168 + if r.Sync.Issues || r.Sync.Patches { 169 + return fmt.Errorf("forgejo_mirror pairs only support sync.git — issues/patches mirroring needs a Radicle side") 170 + } 145 171 } 146 172 if !r.Sync.Git && !r.Sync.Issues && !r.Sync.Patches { 147 173 return fmt.Errorf("at least one of sync.git, sync.issues, sync.patches must be true")
+52
internal/config/config_test.go
··· 1 + package config 2 + 3 + import "testing" 4 + 5 + func validPair() RepoPair { 6 + return RepoPair{ 7 + Name: "p", 8 + Forgejo: ForgejoTarget{BaseURL: "https://f1", Owner: "o", Repo: "r", TokenFile: "/tmp/t"}, 9 + Sync: SyncScope{Git: true}, 10 + } 11 + } 12 + 13 + func TestValidateRequiresRadicleOrForgejoMirror(t *testing.T) { 14 + p := validPair() 15 + if err := p.validate(); err == nil { 16 + t.Fatal("expected error when neither radicle nor forgejo_mirror is set") 17 + } 18 + } 19 + 20 + func TestValidateRejectsBothRadicleAndForgejoMirror(t *testing.T) { 21 + p := validPair() 22 + p.Radicle = &RadicleTarget{RID: "rad:z1", HTTPBaseURL: "https://r1", RadHome: "/home/r"} 23 + p.ForgejoMirror = &ForgejoTarget{BaseURL: "https://f2", Owner: "o", Repo: "r", TokenFile: "/tmp/t2"} 24 + if err := p.validate(); err == nil { 25 + t.Fatal("expected error when both radicle and forgejo_mirror are set") 26 + } 27 + } 28 + 29 + func TestValidateAcceptsRadicleOnly(t *testing.T) { 30 + p := validPair() 31 + p.Radicle = &RadicleTarget{RID: "rad:z1", HTTPBaseURL: "https://r1", RadHome: "/home/r"} 32 + if err := p.validate(); err != nil { 33 + t.Fatalf("unexpected error: %v", err) 34 + } 35 + } 36 + 37 + func TestValidateAcceptsForgejoMirrorOnly(t *testing.T) { 38 + p := validPair() 39 + p.ForgejoMirror = &ForgejoTarget{BaseURL: "https://f2", Owner: "o", Repo: "r", TokenFile: "/tmp/t2"} 40 + if err := p.validate(); err != nil { 41 + t.Fatalf("unexpected error: %v", err) 42 + } 43 + } 44 + 45 + func TestValidateRejectsIssuesOrPatchesOnForgejoMirror(t *testing.T) { 46 + p := validPair() 47 + p.ForgejoMirror = &ForgejoTarget{BaseURL: "https://f2", Owner: "o", Repo: "r", TokenFile: "/tmp/t2"} 48 + p.Sync.Issues = true 49 + if err := p.validate(); err == nil { 50 + t.Fatal("expected error: forgejo_mirror pairs don't support issue sync") 51 + } 52 + }
+5
internal/state/store.go
··· 327 327 const ( 328 328 ForgejoToRadicle = "forgejo_to_radicle" 329 329 RadicleToForgejo = "radicle_to_forgejo" 330 + // ForgejoAToForgejoB and ForgejoBToForgejoA are for Radicle-less pairs 331 + // (config.RepoPair.ForgejoMirror): "a" is pair.Forgejo, "b" is 332 + // pair.ForgejoMirror. 333 + ForgejoAToForgejoB = "forgejo_a_to_forgejo_b" 334 + ForgejoBToForgejoA = "forgejo_b_to_forgejo_a" 330 335 ) 331 336 332 337 // LogActivity records one Activity and returns its new row id — used by
+211
internal/sync/git_ff.go
··· 1 + package sync 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "fmt" 7 + "os" 8 + "os/exec" 9 + "path/filepath" 10 + "strings" 11 + 12 + "graft/internal/state" 13 + ) 14 + 15 + // GitSyncerFF mirrors a repository's default branch between two Forgejo 16 + // instances (no Radicle side involved), the same fast-forward-only way 17 + // GitSyncer mirrors Forgejo<->Radicle: a real divergence is reported, never 18 + // force-pushed. "a" is the pair's primary Forgejo (pair.Forgejo); "b" is its 19 + // mirror (pair.ForgejoMirror). 20 + type GitSyncerFF struct { 21 + RepoPair string 22 + WorkDir string 23 + AURL string // authenticated clone URL (token embedded) 24 + AWebURL string 25 + BURL string 26 + BWebURL string 27 + DefaultBranch string 28 + State *state.Store 29 + Series string 30 + SeriesURL string 31 + Bluesky *BlueskyPoster 32 + // AuthorizedIntegrationSource mirrors GitSyncer's field of the same 33 + // name — kept here for symmetry (SetAuthorizedIntegrationSource is 34 + // called uniformly across every pair in a series) even though 35 + // detectAuthorizedIntegrations doesn't yet look at ForgejoMirror hosts. 36 + AuthorizedIntegrationSource string 37 + aiSkipStreak int 38 + } 39 + 40 + func (g *GitSyncerFF) run(args ...string) (string, error) { 41 + ctx, cancel := context.WithTimeout(context.Background(), execTimeout) 42 + defer cancel() 43 + cmd := exec.CommandContext(ctx, "git", args...) 44 + cmd.Dir = g.WorkDir 45 + var out bytes.Buffer 46 + cmd.Stdout = &out 47 + cmd.Stderr = &out 48 + err := cmd.Run() 49 + if err != nil { 50 + return out.String(), fmt.Errorf("git %v: %w: %s", args, err, out.String()) 51 + } 52 + return out.String(), nil 53 + } 54 + 55 + func (g *GitSyncerFF) 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.DefaultBranch); err != nil { 63 + return err 64 + } 65 + if _, err := g.run("remote", "add", "a", g.AURL); err != nil { 66 + return err 67 + } 68 + if _, err := g.run("remote", "add", "b", g.BURL); err != nil { 69 + return err 70 + } 71 + return nil 72 + } 73 + 74 + func (g *GitSyncerFF) headOf(remote, branch string) (string, error) { 75 + out, err := g.run("ls-remote", remote, "refs/heads/"+branch) 76 + if err != nil { 77 + return "", err 78 + } 79 + if out == "" { 80 + return "", nil 81 + } 82 + var sha string 83 + fmt.Sscanf(out, "%s", &sha) 84 + return sha, nil 85 + } 86 + 87 + func (g *GitSyncerFF) isAncestor(a, b string) bool { 88 + _, err := g.run("merge-base", "--is-ancestor", a, b) 89 + return err == nil 90 + } 91 + 92 + // Sync mirrors the default branch in whichever direction has new, 93 + // fast-forwardable commits — same logic as GitSyncer.Sync, between "a" and 94 + // "b" instead of "forgejo" and "rad". 95 + func (g *GitSyncerFF) Sync() error { 96 + if err := g.ensureClone(); err != nil { 97 + return err 98 + } 99 + 100 + aHead, err := g.headOf("a", g.DefaultBranch) 101 + if err != nil { 102 + return fmt.Errorf("read a head: %w", err) 103 + } 104 + bHead, err := g.headOf("b", g.DefaultBranch) 105 + if err != nil { 106 + return fmt.Errorf("read b head: %w", err) 107 + } 108 + 109 + lastA, lastB, err := g.State.GitCursor(g.RepoPair) 110 + if err != nil { 111 + return fmt.Errorf("read cursor: %w", err) 112 + } 113 + 114 + switch { 115 + case aHead == bHead: 116 + g.aiSkipStreak = 0 117 + 118 + case bHead == "" || bHead == lastB: 119 + if err := g.pushBranch("a", "b", bHead, state.ForgejoAToForgejoB); err != nil { 120 + return fmt.Errorf("mirror a -> b: %w", err) 121 + } 122 + 123 + case aHead == "" || aHead == lastA: 124 + if g.AuthorizedIntegrationSource != "" && g.aiSkipStreak < maxAISkipStreak { 125 + g.aiSkipStreak++ 126 + break 127 + } 128 + if err := g.pushBranch("b", "a", aHead, state.ForgejoBToForgejoA); err != nil { 129 + return fmt.Errorf("mirror b -> a: %w", err) 130 + } 131 + 132 + case g.isAncestor(lastA, aHead) && g.isAncestor(lastB, bHead) && g.isAncestor(bHead, aHead): 133 + if err := g.pushBranch("a", "b", bHead, state.ForgejoAToForgejoB); err != nil { 134 + return fmt.Errorf("mirror a -> b: %w", err) 135 + } 136 + 137 + case g.isAncestor(aHead, bHead): 138 + if g.AuthorizedIntegrationSource != "" && g.aiSkipStreak < maxAISkipStreak { 139 + g.aiSkipStreak++ 140 + break 141 + } 142 + if err := g.pushBranch("b", "a", aHead, state.ForgejoBToForgejoA); err != nil { 143 + return fmt.Errorf("mirror b -> a: %w", err) 144 + } 145 + 146 + default: 147 + return fmt.Errorf( 148 + "%s: a and b diverged since last sync (a=%s b=%s, last a=%s b=%s); resolve manually, not auto-merging", 149 + g.RepoPair, aHead, bHead, lastA, lastB) 150 + } 151 + 152 + aHead, err = g.headOf("a", g.DefaultBranch) 153 + if err != nil { 154 + return err 155 + } 156 + bHead, err = g.headOf("b", g.DefaultBranch) 157 + if err != nil { 158 + return err 159 + } 160 + return g.State.SetGitCursor(g.RepoPair, aHead, bHead) 161 + } 162 + 163 + func (g *GitSyncerFF) pushBranch(from, to, oldToHead, direction string) error { 164 + if _, err := g.run("fetch", "-q", from, g.DefaultBranch); err != nil { 165 + return fmt.Errorf("fetch %s: %w", from, err) 166 + } 167 + if _, err := g.run("push", to, "FETCH_HEAD:refs/heads/"+g.DefaultBranch); err != nil { 168 + return fmt.Errorf("push %s: %w", to, err) 169 + } 170 + g.logMirroredCommits(oldToHead, direction, to) 171 + return nil 172 + } 173 + 174 + func (g *GitSyncerFF) logMirroredCommits(oldHead, direction, to string) { 175 + rangeSpec := "FETCH_HEAD" 176 + if oldHead != "" { 177 + rangeSpec = oldHead + "..FETCH_HEAD" 178 + } 179 + out, err := g.run("log", "--pretty=format:%H%x1f%h %s", rangeSpec) 180 + if err != nil || out == "" { 181 + return 182 + } 183 + webURL := g.AWebURL 184 + if to == "b" { 185 + webURL = g.BWebURL 186 + } 187 + for _, line := range strings.Split(out, "\n") { 188 + if line == "" { 189 + continue 190 + } 191 + full, summary, ok := strings.Cut(line, "\x1f") 192 + if !ok { 193 + continue 194 + } 195 + url := "" 196 + if webURL != "" { 197 + url = webURL + "/commit/" + full 198 + } 199 + id, _ := g.State.LogActivity(state.Activity{ 200 + RepoPair: g.RepoPair, Series: g.Series, SeriesURL: g.SeriesURL, 201 + Kind: "git", Direction: direction, Summary: summary, URL: url, 202 + }) 203 + text := g.Series + ": " + summary 204 + if url != "" { 205 + text += "\n" + url 206 + } 207 + if postURI, err := g.Bluesky.Post(text); err == nil && postURI != "" { 208 + g.State.SaveATProtoPost(postURI, id, g.Series) 209 + } 210 + } 211 + }
+53 -9
internal/sync/syncer.go
··· 20 20 forgejo *forgejo.Client 21 21 radicle *radicle.Client 22 22 git *GitSyncer 23 + gitFF *GitSyncerFF // set instead of git when pair.ForgejoMirror is used 23 24 issues *IssueSyncer 24 25 patches *PatchSyncer 25 26 } ··· 38 39 return nil, fmt.Errorf("resolve forgejo repository: %w", err) 39 40 } 40 41 41 - rc := radicle.New(pair.Radicle.HTTPBaseURL, pair.Radicle.RID, pair.Radicle.RadHome, "rad") 42 - 43 - rs := &RepoSyncer{pair: pair, forgejo: fc, radicle: rc} 42 + rs := &RepoSyncer{pair: pair, forgejo: fc} 44 43 45 44 forgejoWebURL := strings.TrimRight(pair.Forgejo.BaseURL, "/") + "/" + pair.Forgejo.Owner + "/" + pair.Forgejo.Repo 46 - radicleWebURL := RadicleExplorerLink(pair.Radicle) 47 45 48 46 series := pair.Series 49 47 if series == "" { 50 48 series = pair.Name 51 49 } 52 - seriesURL := radicleWebURL 53 - if seriesURL == "" { 54 - seriesURL = forgejoWebURL 55 - } 56 50 57 51 var bluesky *BlueskyPoster 58 52 if pair.Bluesky != nil { ··· 67 61 } 68 62 } 69 63 64 + if pair.ForgejoMirror != nil { 65 + mirrorToken, err := config.ReadToken(pair.ForgejoMirror.TokenFile) 66 + if err != nil { 67 + return nil, err 68 + } 69 + mfc := forgejo.New(pair.ForgejoMirror.BaseURL, pair.ForgejoMirror.Owner, pair.ForgejoMirror.Repo, mirrorToken) 70 + mrepo, err := mfc.GetRepository() 71 + if err != nil { 72 + return nil, fmt.Errorf("resolve forgejo_mirror repository: %w", err) 73 + } 74 + mirrorWebURL := strings.TrimRight(pair.ForgejoMirror.BaseURL, "/") + "/" + pair.ForgejoMirror.Owner + "/" + pair.ForgejoMirror.Repo 75 + 76 + if pair.Sync.Git { 77 + rs.gitFF = &GitSyncerFF{ 78 + RepoPair: pair.Name, 79 + WorkDir: workDir, 80 + AURL: authenticatedCloneURL(repo.CloneURL, token), 81 + AWebURL: forgejoWebURL, 82 + BURL: authenticatedCloneURL(mrepo.CloneURL, mirrorToken), 83 + BWebURL: mirrorWebURL, 84 + DefaultBranch: repo.DefaultBranch, 85 + State: st, 86 + Series: series, 87 + SeriesURL: forgejoWebURL, 88 + Bluesky: bluesky, 89 + } 90 + } 91 + return rs, nil 92 + } 93 + 94 + rc := radicle.New(pair.Radicle.HTTPBaseURL, pair.Radicle.RID, pair.Radicle.RadHome, "rad") 95 + rs.radicle = rc 96 + 97 + radicleWebURL := RadicleExplorerLink(*pair.Radicle) 98 + seriesURL := radicleWebURL 99 + if seriesURL == "" { 100 + seriesURL = forgejoWebURL 101 + } 102 + 70 103 if pair.Sync.Git || pair.Sync.Patches { 71 104 rs.git = &GitSyncer{ 72 105 RepoPair: pair.Name, ··· 150 183 if rs.git != nil { 151 184 rs.git.AuthorizedIntegrationSource = host 152 185 } 186 + if rs.gitFF != nil { 187 + rs.gitFF.AuthorizedIntegrationSource = host 188 + } 153 189 } 154 190 155 191 // HasAuthorizedIntegrationSource reports whether this pair's Forgejo side ··· 158 194 // startup) — used by the dashboard to explain why this side shows no 159 195 // graft-logged events even though it holds the mirrored content. 160 196 func (rs *RepoSyncer) HasAuthorizedIntegrationSource() bool { 161 - return rs.git != nil && rs.git.AuthorizedIntegrationSource != "" 197 + if rs.git != nil && rs.git.AuthorizedIntegrationSource != "" { 198 + return true 199 + } 200 + return rs.gitFF != nil && rs.gitFF.AuthorizedIntegrationSource != "" 162 201 } 163 202 164 203 // Series is the dashboard row this pair's activity groups under: its ··· 229 268 230 269 if rs.git != nil { 231 270 if gitErr = rs.git.Sync(); gitErr != nil { 271 + log.Error("git sync failed", "err", gitErr) 272 + } 273 + } 274 + if rs.gitFF != nil { 275 + if gitErr = rs.gitFF.Sync(); gitErr != nil { 232 276 log.Error("git sync failed", "err", gitErr) 233 277 } 234 278 }