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
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}