Federated via graft
5.8 kB
211 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
40func (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
55func (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
74func (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
87func (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".
95func (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
163func (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
174func (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}