Federated via graft
6.1 kB
215 lines
1package sync
2
3import (
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).
20type 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// run captures stdout and stderr separately — a git warning (e.g. "warning:
41// redirecting to ..." on a renamed repo, printed to stderr) must never land
42// in the stdout callers like headOf parse for a commit SHA. See git.go's
43// run, which has the same split for the same reason.
44func (g *GitSyncerFF) run(args ...string) (string, error) {
45 ctx, cancel := context.WithTimeout(context.Background(), execTimeout)
46 defer cancel()
47 cmd := exec.CommandContext(ctx, "git", args...)
48 cmd.Dir = g.WorkDir
49 var out, errOut bytes.Buffer
50 cmd.Stdout = &out
51 cmd.Stderr = &errOut
52 err := cmd.Run()
53 if err != nil {
54 return out.String(), fmt.Errorf("git %v: %w: %s", args, err, errOut.String())
55 }
56 return out.String(), nil
57}
58
59func (g *GitSyncerFF) ensureClone() error {
60 if _, err := os.Stat(filepath.Join(g.WorkDir, ".git")); err == nil {
61 return nil
62 }
63 if err := os.MkdirAll(g.WorkDir, 0o700); err != nil {
64 return fmt.Errorf("create work dir: %w", err)
65 }
66 if _, err := g.run("init", "-q", "-b", g.DefaultBranch); err != nil {
67 return err
68 }
69 if _, err := g.run("remote", "add", "a", g.AURL); err != nil {
70 return err
71 }
72 if _, err := g.run("remote", "add", "b", g.BURL); err != nil {
73 return err
74 }
75 return nil
76}
77
78func (g *GitSyncerFF) headOf(remote, branch string) (string, error) {
79 out, err := g.run("ls-remote", remote, "refs/heads/"+branch)
80 if err != nil {
81 return "", err
82 }
83 if out == "" {
84 return "", nil
85 }
86 var sha string
87 fmt.Sscanf(out, "%s", &sha)
88 return sha, nil
89}
90
91func (g *GitSyncerFF) isAncestor(a, b string) bool {
92 _, err := g.run("merge-base", "--is-ancestor", a, b)
93 return err == nil
94}
95
96// Sync mirrors the default branch in whichever direction has new,
97// fast-forwardable commits — same logic as GitSyncer.Sync, between "a" and
98// "b" instead of "forgejo" and "rad".
99func (g *GitSyncerFF) Sync() error {
100 if err := g.ensureClone(); err != nil {
101 return err
102 }
103
104 aHead, err := g.headOf("a", g.DefaultBranch)
105 if err != nil {
106 return fmt.Errorf("read a head: %w", err)
107 }
108 bHead, err := g.headOf("b", g.DefaultBranch)
109 if err != nil {
110 return fmt.Errorf("read b head: %w", err)
111 }
112
113 lastA, lastB, err := g.State.GitCursor(g.RepoPair)
114 if err != nil {
115 return fmt.Errorf("read cursor: %w", err)
116 }
117
118 switch {
119 case aHead == bHead:
120 g.aiSkipStreak = 0
121
122 case bHead == "" || bHead == lastB:
123 if err := g.pushBranch("a", "b", bHead, state.ForgejoAToForgejoB); err != nil {
124 return fmt.Errorf("mirror a -> b: %w", err)
125 }
126
127 case aHead == "" || aHead == lastA:
128 if g.AuthorizedIntegrationSource != "" && g.aiSkipStreak < maxAISkipStreak {
129 g.aiSkipStreak++
130 break
131 }
132 if err := g.pushBranch("b", "a", aHead, state.ForgejoBToForgejoA); err != nil {
133 return fmt.Errorf("mirror b -> a: %w", err)
134 }
135
136 case g.isAncestor(lastA, aHead) && g.isAncestor(lastB, bHead) && g.isAncestor(bHead, aHead):
137 if err := g.pushBranch("a", "b", bHead, state.ForgejoAToForgejoB); err != nil {
138 return fmt.Errorf("mirror a -> b: %w", err)
139 }
140
141 case g.isAncestor(aHead, bHead):
142 if g.AuthorizedIntegrationSource != "" && g.aiSkipStreak < maxAISkipStreak {
143 g.aiSkipStreak++
144 break
145 }
146 if err := g.pushBranch("b", "a", aHead, state.ForgejoBToForgejoA); err != nil {
147 return fmt.Errorf("mirror b -> a: %w", err)
148 }
149
150 default:
151 return fmt.Errorf(
152 "%s: a and b diverged since last sync (a=%s b=%s, last a=%s b=%s); resolve manually, not auto-merging",
153 g.RepoPair, aHead, bHead, lastA, lastB)
154 }
155
156 aHead, err = g.headOf("a", g.DefaultBranch)
157 if err != nil {
158 return err
159 }
160 bHead, err = g.headOf("b", g.DefaultBranch)
161 if err != nil {
162 return err
163 }
164 return g.State.SetGitCursor(g.RepoPair, aHead, bHead)
165}
166
167func (g *GitSyncerFF) pushBranch(from, to, oldToHead, direction string) error {
168 if _, err := g.run("fetch", "-q", from, g.DefaultBranch); err != nil {
169 return fmt.Errorf("fetch %s: %w", from, err)
170 }
171 if _, err := g.run("push", to, "FETCH_HEAD:refs/heads/"+g.DefaultBranch); err != nil {
172 return fmt.Errorf("push %s: %w", to, err)
173 }
174 g.logMirroredCommits(oldToHead, direction, to)
175 return nil
176}
177
178func (g *GitSyncerFF) logMirroredCommits(oldHead, direction, to string) {
179 rangeSpec := "FETCH_HEAD"
180 if oldHead != "" {
181 rangeSpec = oldHead + "..FETCH_HEAD"
182 }
183 out, err := g.run("log", "--pretty=format:%H%x1f%h %s", rangeSpec)
184 if err != nil || out == "" {
185 return
186 }
187 webURL := g.AWebURL
188 if to == "b" {
189 webURL = g.BWebURL
190 }
191 for _, line := range strings.Split(out, "\n") {
192 if line == "" {
193 continue
194 }
195 full, summary, ok := strings.Cut(line, "\x1f")
196 if !ok {
197 continue
198 }
199 url := ""
200 if webURL != "" {
201 url = webURL + "/commit/" + full
202 }
203 id, _ := g.State.LogActivity(state.Activity{
204 RepoPair: g.RepoPair, Series: g.Series, SeriesURL: g.SeriesURL,
205 Kind: "git", Direction: direction, Summary: summary, URL: url,
206 })
207 text := g.Series + ": " + summary
208 if url != "" {
209 text += "\n" + url
210 }
211 if postURI, err := g.Bluesky.Post(text); err == nil && postURI != "" {
212 g.State.SaveATProtoPost(postURI, id, g.Series)
213 }
214 }
215}