This repository has no description
0

Configure Feed

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

core / knotserver / xrpc / git_merge_check.go
7.5 kB 225 lines
1package xrpc 2 3import ( 4 "context" 5 "crypto/sha256" 6 "encoding/json" 7 "fmt" 8 "net/http" 9 "net/url" 10 "os" 11 "strings" 12 13 "github.com/bluesky-social/indigo/atproto/atclient" 14 "github.com/bluesky-social/indigo/atproto/syntax" 15 "github.com/dgraph-io/ristretto" 16 "tangled.org/core/api/tangled" 17) 18 19type MergeInput struct { 20 TargetRepo syntax.DID 21 TargetBranch string 22 SourceRepo syntax.DID 23 SourceCommit string 24} 25 26type MergeCheckCache struct { 27 cache *ristretto.Cache 28} 29 30func (m *MergeCheckCache) cacheKey(input MergeInput) string { 31 raw := strings.Join([]string{ 32 input.TargetRepo.String(), 33 input.TargetBranch, 34 input.SourceRepo.String(), 35 input.SourceCommit, 36 }, "\x00") 37 sum := sha256.Sum256([]byte(raw)) 38 return fmt.Sprintf("%x", sum) 39} 40 41func (m *MergeCheckCache) cacheVal(out *tangled.GitMergeCheck_Output) any { 42 return *out 43} 44 45func (m *MergeCheckCache) Set(input MergeInput, mergeCheck *tangled.GitMergeCheck_Output) { 46 key := m.cacheKey(input) 47 val := m.cacheVal(mergeCheck) 48 m.cache.Set(key, val, 0) 49} 50 51func (m *MergeCheckCache) Get(input MergeInput) (tangled.GitMergeCheck_Output, bool) { 52 key := m.cacheKey(input) 53 if val, ok := m.cache.Get(key); ok { 54 if out, ok := val.(tangled.GitMergeCheck_Output); ok { 55 // cache hit 56 return out, true 57 } 58 } 59 60 // cache miss 61 return tangled.GitMergeCheck_Output{}, false 62} 63 64var mergeCheckCache MergeCheckCache 65 66func init() { 67 cache, _ := ristretto.NewCache(&ristretto.Config{ 68 NumCounters: 1e7, 69 MaxCost: 1 << 30, 70 BufferItems: 64, 71 TtlTickerDurationInSec: 60 * 60 * 24 * 2, // 2 days 72 }) 73 mergeCheckCache = MergeCheckCache{cache} 74} 75 76func (x *Xrpc) GitMergeCheck(w http.ResponseWriter, r *http.Request) { 77 var input tangled.GitMergeCheck_Input 78 if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 79 writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: "failed to decode json body"}) 80 return 81 } 82 l := x.Logger.With("handler", "MergeCheck2", "input", input) 83 l.Debug("request") 84 85 if err := gitMergeCheck_Input_Validate(input); err != nil { 86 writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "InvalidRequest", Message: err.Error()}) 87 return 88 } 89 90 mergeInput := MergeInput{ 91 TargetRepo: syntax.DID(input.Repo), 92 TargetBranch: input.Branch, 93 SourceRepo: syntax.DID(input.Source.Repo), 94 SourceCommit: input.Source.Commit, 95 } 96 97 // check cache 98 if cached, ok := mergeCheckCache.Get(mergeInput); ok { 99 l.Debug("cache hit") 100 writeJson(w, http.StatusOK, cached) 101 return 102 } 103 104 output, status, apierr := x.mergeCheck(r.Context(), input) 105 if apierr != nil { 106 l.Error("failed", "kind", apierr.Name, "error", apierr.Message) 107 writeJson(w, status, apierr) 108 return 109 } 110 111 // update cache 112 mergeCheckCache.Set(mergeInput, &output) 113 114 writeJson(w, status, output) 115} 116 117func (x *Xrpc) mergeCheck(ctx context.Context, input tangled.GitMergeCheck_Input) (tangled.GitMergeCheck_Output, int, *atclient.ErrorBody) { 118 l := x.Logger.With("handler", "mergeCheck") 119 120 fail := func(status int, name, clientMsg string, detail ...any) (tangled.GitMergeCheck_Output, int, *atclient.ErrorBody) { 121 l.Error(clientMsg, append([]any{"name", name}, detail...)...) 122 return tangled.GitMergeCheck_Output{}, status, &atclient.ErrorBody{Name: name, Message: clientMsg} 123 } 124 125 baseRepoPath, _, _, err := x.Db.ResolveRepoDIDOnDisk(x.Config.Repo.ScanPath, input.Repo) 126 if err != nil { 127 return fail(http.StatusNotFound, "RepoNotFound", "unknown repository", "repo", input.Repo, "err", err) 128 } 129 sourceRepoDid := syntax.DID(input.Source.Repo) 130 131 var sourceRepoUrl string 132 var sourceRepoPath string // non-empty only when the source is local to this knot 133 if p, _, _, err := x.Db.ResolveRepoDIDOnDisk(x.Config.Repo.ScanPath, input.Source.Repo); err == nil { 134 sourceRepoPath = p 135 sourceRepoUrl = "file://" + p 136 } else { 137 ident, err := x.Resolver.Directory().LookupDID(ctx, sourceRepoDid) 138 if err != nil { 139 return fail(http.StatusNotFound, "RepoNotFound", "unknown repository", "source", sourceRepoDid, "err", err) 140 } 141 sourceKnot := ident.GetServiceEndpoint("atproto_pds") 142 u, err := url.Parse(sourceKnot) 143 if err != nil { 144 return fail(http.StatusNotFound, "RepoNotFound", "unknown repository", "source", sourceRepoDid, "knot", sourceKnot, "err", err) 145 } 146 sourceRepoUrl = u.JoinPath(sourceRepoDid.String()).String() 147 } 148 149 env := append(os.Environ(), "GIT_TERMINAL_PROMPT=0") 150 151 // 1. create temp repo with git alternate to the base repo's objects. 152 tmpRepoPath, cleanup, err := createTemporaryRepoForMerge(ctx, x.Sandbox, baseRepoPath, input.Branch) 153 if err != nil { 154 return fail(http.StatusInternalServerError, "InternalError", "failed to prepare merge check", "err", err) 155 } 156 defer cleanup() 157 158 runGit := func(args ...string) ([]byte, []byte, error) { 159 args = append([]string{"-C", tmpRepoPath}, args...) 160 return gitWithSandbox(ctx, x.Sandbox, env, []string{tmpRepoPath}, args...) 161 } 162 163 // 2. fetch source commit and pin it to a "tracking" branch. 164 fetchPaths := []string{tmpRepoPath} 165 if sourceRepoPath != "" { 166 fetchPaths = append(fetchPaths, sourceRepoPath) 167 } 168 if _, stderr, err := gitWithSandbox(ctx, x.Sandbox, env, fetchPaths, "-C", tmpRepoPath, "fetch", sourceRepoUrl, input.Source.Commit); err != nil { 169 return fail(http.StatusNotFound, "CommitNotFound", "source commit unavailable", "commit", input.Source.Commit, "err", err, "stderr", strings.TrimSpace(string(stderr))) 170 } 171 if _, stderr, err := runGit("branch", "tracking", input.Source.Commit); err != nil { 172 return fail(http.StatusNotFound, "CommitNotFound", "source commit unavailable", "commit", input.Source.Commit, "err", err, "stderr", strings.TrimSpace(string(stderr))) 173 } 174 175 // 3. populate the working tree on the base branch. 176 if _, stderr, err := runGit("checkout", "-f", "base"); err != nil { 177 return fail(http.StatusInternalServerError, "InternalError", "failed to perform merge check", "step", "checkout base", "err", err, "stderr", strings.TrimSpace(string(stderr))) 178 } 179 180 // 4. attempt a 3-way merge without committing. 181 if _, stderr, err := runGit("merge", "--no-commit", "--no-ff", "tracking"); err != nil { 182 lsOut, _, _ := runGit("ls-files", "--unmerged") 183 files := parseUnmergedFiles(lsOut) 184 if len(files) == 0 { 185 return fail(http.StatusInternalServerError, "InternalError", "failed to perform merge check", "step", "merge", "err", err, "stderr", strings.TrimSpace(string(stderr))) 186 } 187 188 conflicts := make([]*tangled.GitMergeCheck_ConflictInfo, 0, len(files)) 189 for _, f := range files { 190 conflicts = append(conflicts, &tangled.GitMergeCheck_ConflictInfo{ 191 Filename: f, 192 Reason: "merge conflict", 193 }) 194 } 195 msg := strings.TrimSpace(string(stderr)) 196 l.Debug("merge check found conflicts", "files", files) 197 return tangled.GitMergeCheck_Output{ 198 IsConflicted: true, 199 Conflicts: conflicts, 200 Message: &msg, 201 }, http.StatusOK, nil 202 } 203 204 return tangled.GitMergeCheck_Output{IsConflicted: false}, http.StatusOK, nil 205} 206 207// lexgen doesn't give Validate() method... 208func gitMergeCheck_Input_Validate(input tangled.GitMergeCheck_Input) error { 209 if _, err := syntax.ParseDID(input.Repo); err != nil { 210 return fmt.Errorf("repo: invalid DID: %w", err) 211 } 212 if input.Branch == "" { 213 return fmt.Errorf("branch: required") 214 } 215 if input.Source == nil { 216 return fmt.Errorf("source: required") 217 } 218 if _, err := syntax.ParseDID(input.Source.Repo); err != nil { 219 return fmt.Errorf("source.repo: invalid DID: %w", err) 220 } 221 if input.Source.Commit == "" { 222 return fmt.Errorf("source.commit: required") 223 } 224 return nil 225}