Federated via graft
0

Configure Feed

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

graft-source / internal / sync / git_ff.go
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}