This repository has no description
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}