This repository has no description
16 kB
374 lines
1package xrpc
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
8 "log/slog"
9 "net/http"
10 "net/url"
11 "os"
12 "os/exec"
13 "path/filepath"
14 "strings"
15
16 "github.com/bluesky-social/indigo/atproto/atclient"
17 "github.com/bluesky-social/indigo/atproto/syntax"
18 "tangled.org/core/api/tangled"
19 "tangled.org/core/knotserver/sandbox"
20 "tangled.org/core/rbac"
21)
22
23func (x *Xrpc) MergeCommit(w http.ResponseWriter, r *http.Request) {
24 var input tangled.GitMergeCommit_Input
25 if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
26 writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: "failed to decode json body"})
27 return
28 }
29 l := x.Logger.With("handler", "MergeCommit", "input", input)
30 l.Debug("request")
31
32 if err := gitMergeCommit_Input_Validate(input); err != nil {
33 writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: err.Error()})
34 return
35 }
36
37 actorDid, ok := r.Context().Value(ActorDid).(syntax.DID)
38 if !ok {
39 writeJson(w, http.StatusUnauthorized, &atclient.ErrorBody{Name: "Unauthorized", Message: "missing actor DID"})
40 return
41 }
42 if allowed, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, input.Target.Repo); err != nil || !allowed {
43 writeJson(w, http.StatusUnauthorized, &atclient.ErrorBody{Name: "Forbidden", Message: fmt.Sprintf("%s is not allowed to merge into this repository", actorDid.String())})
44 return
45 }
46
47 output, status, apierr := x.mergeCommit(r.Context(), input)
48 if apierr != nil {
49 l.Error("failed", "kind", apierr.Name, "error", apierr.Message)
50 writeJson(w, status, apierr)
51 return
52 }
53
54 writeJson(w, status, output)
55}
56
57func (x *Xrpc) mergeCommit(ctx context.Context, input tangled.GitMergeCommit_Input) (any, int, *atclient.ErrorBody) {
58 l := x.Logger.With("handler", "mergePullRequest")
59
60 fail := func(status int, name, clientMsg string, detail ...any) (any, int, *atclient.ErrorBody) {
61 l.Error(clientMsg, append([]any{"name", name}, detail...)...)
62 return nil, status, &atclient.ErrorBody{Name: name, Message: clientMsg}
63 }
64
65 baseRepoPath, _, _, err := x.Db.ResolveRepoDIDOnDisk(x.Config.Repo.ScanPath, input.Target.Repo)
66 if err != nil {
67 return fail(http.StatusNotFound, "RepoNotFound", "unknown repository", "repo", input.Target.Repo, "err", err)
68 }
69 sourceRepoDid := syntax.DID(input.Source.Repo)
70
71 var sourceRepoUrl string
72 var sourceRepoPath string // non-empty only when the source is local to this knot
73 if p, _, _, err := x.Db.ResolveRepoDIDOnDisk(x.Config.Repo.ScanPath, input.Source.Repo); err == nil {
74 sourceRepoPath = p
75 sourceRepoUrl = "file://" + p
76 } else {
77 ident, err := x.Resolver.Directory().LookupDID(ctx, sourceRepoDid)
78 if err != nil {
79 return fail(http.StatusNotFound, "RepoNotFound", "unknown repository", "source", sourceRepoDid, "err", err)
80 }
81 sourceKnot := ident.GetServiceEndpoint("atproto_pds")
82 u, err := url.Parse(sourceKnot)
83 if err != nil {
84 return fail(http.StatusNotFound, "RepoNotFound", "unknown repository", "source", sourceRepoDid, "knot", sourceKnot, "err", err)
85 }
86 sourceRepoUrl = u.JoinPath(sourceRepoDid.String()).String()
87 }
88
89 var authorName, authorEmail, authorDate string
90 if input.MergeCommit != nil && input.MergeCommit.Author != nil {
91 authorName = input.MergeCommit.Author.Name
92 authorEmail = input.MergeCommit.Author.Email
93 authorDate = input.MergeCommit.Author.When
94 } else {
95 authorName = x.Config.Git.UserName
96 authorEmail = x.Config.Git.UserEmail
97 }
98
99 var message string
100 if input.MergeCommit != nil && input.MergeCommit.Message != nil {
101 message = *input.MergeCommit.Message
102 } else {
103 shortSha := input.Source.Commit
104 if len(shortSha) > 8 {
105 shortSha = shortSha[:8]
106 }
107 message = fmt.Sprintf("Merge %s into %s", shortSha, input.Target.Branch)
108 }
109
110 env := append(os.Environ(),
111 "GIT_TERMINAL_PROMPT=0",
112 "GIT_AUTHOR_NAME="+authorName,
113 "GIT_AUTHOR_EMAIL="+authorEmail,
114 "GIT_COMMITTER_NAME="+x.Config.Git.UserName,
115 "GIT_COMMITTER_EMAIL="+x.Config.Git.UserEmail,
116 )
117 if authorDate != "" {
118 env = append(env, "GIT_AUTHOR_DATE="+authorDate)
119 }
120
121 // 1. create temp repo with git alternate
122 tmpRepoPath, cleanup, err := createTemporaryRepoForMerge(ctx, x.Sandbox, baseRepoPath, input.Target.Branch)
123 if err != nil {
124 return fail(http.StatusInternalServerError, "InternalError", "failed to prepare merge", "err", err)
125 }
126 defer cleanup()
127
128 runGit := func(args ...string) ([]byte, []byte, error) {
129 args = append([]string{"-C", tmpRepoPath}, args...)
130 return gitWithSandbox(ctx, x.Sandbox, env, []string{tmpRepoPath}, args...)
131 }
132
133 // 2. fetch source as "tracking" branch
134 fetchPaths := []string{tmpRepoPath}
135 if sourceRepoPath != "" {
136 fetchPaths = append(fetchPaths, sourceRepoPath)
137 }
138 if _, stderr, err := gitWithSandbox(ctx, x.Sandbox, env, fetchPaths, "-C", tmpRepoPath, "fetch", sourceRepoUrl, input.Source.Commit); err != nil {
139 return fail(http.StatusInternalServerError, "InternalError", "failed to fetch source commit", "err", err, "stderr", strings.TrimSpace(string(stderr)))
140 }
141 if _, stderr, err := runGit("branch", "tracking", input.Source.Commit); err != nil {
142 return fail(http.StatusBadRequest, "InvalidCommit", "source commit unavailable", "commit", input.Source.Commit, "err", err, "stderr", strings.TrimSpace(string(stderr)))
143 }
144
145 // populate the working tree on the base branch.
146 if _, stderr, err := runGit("checkout", "-f", "base"); err != nil {
147 return fail(http.StatusInternalServerError, "InternalError", "failed to merge commit", "step", "checkout base", "err", err, "stderr", strings.TrimSpace(string(stderr)))
148 }
149
150 // 3. merge
151 switch input.Style {
152 case "rebase":
153 if status, apierr := rebaseTrackingOntoBase(runGit, l, tmpRepoPath); apierr != nil {
154 return nil, status, apierr
155 }
156 if _, stderr, err := runGit("checkout", "base"); err != nil {
157 return fail(http.StatusInternalServerError, "InternalError", "failed to merge commit", "step", "checkout base after rebase", "err", err, "stderr", strings.TrimSpace(string(stderr)))
158 }
159 if _, stderr, err := runGit("merge", "--ff-only", "staging"); err != nil {
160 return mergeConflictError(runGit, l, stderr)
161 }
162
163 case "merge":
164 if _, stderr, err := runGit("merge", "--no-ff", "--no-commit", "tracking"); err != nil {
165 return mergeConflictError(runGit, l, stderr)
166 }
167 if _, stderr, err := runGit("commit", "--no-gpg-sign", "--message="+message); err != nil {
168 return fail(http.StatusInternalServerError, "InternalError", "failed to merge commit", "step", "merge commit", "err", err, "stderr", strings.TrimSpace(string(stderr)))
169 }
170
171 case "rebase-merge":
172 if status, apierr := rebaseTrackingOntoBase(runGit, l, tmpRepoPath); apierr != nil {
173 return nil, status, apierr
174 }
175 if _, stderr, err := runGit("checkout", "base"); err != nil {
176 return fail(http.StatusInternalServerError, "InternalError", "failed to merge commit", "step", "checkout base after rebase", "err", err, "stderr", strings.TrimSpace(string(stderr)))
177 }
178 if _, stderr, err := runGit("merge", "--no-ff", "--no-commit", "staging"); err != nil {
179 return mergeConflictError(runGit, l, stderr)
180 }
181 if _, stderr, err := runGit("commit", "--no-gpg-sign", "--message="+message); err != nil {
182 return fail(http.StatusInternalServerError, "InternalError", "failed to merge commit", "step", "merge commit", "err", err, "stderr", strings.TrimSpace(string(stderr)))
183 }
184
185 case "squash-rebase":
186 if _, stderr, err := runGit("merge", "--squash", "tracking"); err != nil {
187 return mergeConflictError(runGit, l, stderr)
188 }
189 if _, stderr, err := runGit("commit", "--no-gpg-sign", "--message="+message); err != nil {
190 return fail(http.StatusInternalServerError, "InternalError", "failed to merge commit", "step", "squash commit", "err", err, "stderr", strings.TrimSpace(string(stderr)))
191 }
192
193 case "squash-merge":
194 // TODO: implement this
195 return nil, http.StatusBadRequest, &atclient.ErrorBody{Name: "BadRequest", Message: fmt.Sprintf("unknown merge style: %q", input.Style)}
196
197 case "squash-rebase-merge":
198 return fail(http.StatusInternalServerError, "InternalError", "squash-rebase-merge is not yet supported")
199
200 case "fast-forward-only":
201 if _, stderr, err := runGit("merge", "--ff-only", "tracking"); err != nil {
202 return fail(http.StatusConflict, "MergeConflict", "cannot fast-forward: source and target have diverged", "commit", input.Source.Commit, "branch", input.Target.Branch, "err", err, "stderr", strings.TrimSpace(string(stderr)))
203 }
204
205 default:
206 return nil, http.StatusBadRequest, &atclient.ErrorBody{Name: "BadRequest", Message: fmt.Sprintf("unknown merge style: %q", input.Style)}
207 }
208
209 // 4. push the merged "base" back to the real base repo's branch. this goes through
210 // receive-pack so the base repo's hooks fire (ref-update notification).
211 if _, stderr, err := gitWithSandbox(ctx, x.Sandbox, env, []string{tmpRepoPath, baseRepoPath}, "-C", tmpRepoPath, "push", "origin", "base:refs/heads/"+input.Target.Branch); err != nil {
212 msg := strings.TrimSpace(string(stderr))
213 if strings.Contains(msg, "non-fast-forward") || strings.Contains(msg, "rejected") {
214 return fail(http.StatusConflict, "PushRejected", "target branch changed; retry the merge", "branch", input.Target.Branch, "err", err, "stderr", msg)
215 }
216 return fail(http.StatusInternalServerError, "InternalError", "failed to complete merge", "step", "push", "err", err, "stderr", msg)
217 }
218
219 return nil, http.StatusOK, nil
220}
221
222// rebaseTrackingOntoBase checks out "tracking" as "staging" and rebases it onto "base".
223func rebaseTrackingOntoBase(run func(...string) ([]byte, []byte, error), l *slog.Logger, tmpRepoPath string) (int, *atclient.ErrorBody) {
224 if _, stderr, err := run("checkout", "-b", "staging", "tracking"); err != nil {
225 l.Error("failed to merge commit", "step", "checkout staging", "err", err, "stderr", strings.TrimSpace(string(stderr)))
226 return http.StatusInternalServerError, &atclient.ErrorBody{Name: "InternalError", Message: "failed to merge commit"}
227 }
228 if _, stderr, err := run("rebase", "base"); err != nil {
229 if _, statErr := os.Stat(filepath.Join(tmpRepoPath, ".git", "REBASE_HEAD")); statErr == nil {
230 l.Error("rebase produced conflicts", "err", err, "stderr", strings.TrimSpace(string(stderr)))
231 return http.StatusConflict, &atclient.ErrorBody{Name: "MergeConflict", Message: "rebase produced conflicts"}
232 }
233 l.Error("failed to merge commit", "step", "rebase", "err", err, "stderr", strings.TrimSpace(string(stderr)))
234 return http.StatusInternalServerError, &atclient.ErrorBody{Name: "InternalError", Message: "failed to merge commit"}
235 }
236 return 0, nil
237}
238
239// mergeConflictError inspects the temp repo for unmerged paths after a failed merge and
240// returns a 409 listing them, falling back to a 500 when the failure is not a conflict.
241func mergeConflictError(runGit func(...string) ([]byte, []byte, error), l *slog.Logger, mergeStderr []byte) (any, int, *atclient.ErrorBody) {
242 stdout, _, _ := runGit("ls-files", "--unmerged")
243 files := parseUnmergedFiles(stdout)
244 if len(files) > 0 {
245 l.Error("merge produced conflicts", "files", files, "stderr", strings.TrimSpace(string(mergeStderr)))
246 return nil, http.StatusConflict, &atclient.ErrorBody{Name: "MergeConflict", Message: "merge produced conflicts"}
247 }
248 l.Error("failed to merge commit", "step", "merge", "stderr", strings.TrimSpace(string(mergeStderr)))
249 return nil, http.StatusInternalServerError, &atclient.ErrorBody{Name: "InternalError", Message: "failed to merge commit"}
250}
251
252// parseUnmergedFiles parses `git ls-files --unmerged` output into a deduplicated list of paths.
253// Each line looks like: "<mode> <sha> <stage>\t<path>".
254func parseUnmergedFiles(out []byte) []string {
255 seen := make(map[string]struct{})
256 var files []string
257 for line := range strings.SplitSeq(string(out), "\n") {
258 _, path, ok := strings.Cut(line, "\t")
259 if !ok {
260 continue
261 }
262 if _, ok := seen[path]; ok {
263 continue
264 }
265 seen[path] = struct{}{}
266 files = append(files, path)
267 }
268 return files
269}
270
271// lexgen doesn't give Validate() method...
272func gitMergeCommit_Input_Validate(input tangled.GitMergeCommit_Input) error {
273 if input.Target == nil {
274 return fmt.Errorf("target: required")
275 }
276 if input.Source == nil {
277 return fmt.Errorf("source: required")
278 }
279 if _, err := syntax.ParseDID(input.Target.Repo); err != nil {
280 return fmt.Errorf("target.repo: invalid DID: %w", err)
281 }
282 if input.Target.Branch == "" {
283 return fmt.Errorf("target.branch: required")
284 }
285 if _, err := syntax.ParseDID(input.Source.Repo); err != nil {
286 return fmt.Errorf("source.repo: invalid DID: %w", err)
287 }
288 if input.Source.Commit == "" {
289 return fmt.Errorf("source.commit: required")
290 }
291 switch input.Style {
292 case "merge", "rebase", "rebase-merge", "squash", "fast-forward-only":
293 default:
294 return fmt.Errorf("style: unknown merge style %q", input.Style)
295 }
296 return nil
297}
298
299// createTemporaryRepoForMerge creates a temporary non-bare repo with the base repo's
300// "base" branch (and a copy "original_base") checked out via a git alternate to the base
301// repo's object store. Returns the temp repo path and a cleanup func that removes it.
302func createTemporaryRepoForMerge(ctx context.Context, sb sandbox.Backend, baseRepoPath string, baseBranch string) (path string, cleanup context.CancelFunc, err error) {
303 tmp, err := os.MkdirTemp("", "merge-*")
304 if err != nil {
305 return "", nil, fmt.Errorf("create temp dir: %w", err)
306 }
307 cleanup = func() { os.RemoveAll(tmp) }
308
309 env := append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
310 run := func(paths []string, args ...string) ([]byte, []byte, error) {
311 return gitWithSandbox(ctx, sb, env, paths, args...)
312 }
313
314 // git init needs a working dir (non-bare).
315 if _, stderr, err := run([]string{tmp}, "-C", tmp, "init"); err != nil {
316 cleanup()
317 return "", nil, fmt.Errorf("git init: %s", strings.TrimSpace(string(stderr)))
318 }
319
320 // borrow the base repo's objects via alternates.
321 if err := func(repoPath, srcRepoPath string) error {
322 p := filepath.Join(repoPath, ".git", "objects", "info", "alternates")
323 f, err := os.OpenFile(p, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
324 if err != nil {
325 return err
326 }
327 defer f.Close()
328 _, err = fmt.Fprintln(f, filepath.Join(srcRepoPath, "objects"))
329 return err
330 }(tmp, baseRepoPath); err != nil {
331 cleanup()
332 return "", nil, fmt.Errorf("add base objects: %w", err)
333 }
334
335 if _, stderr, err := run([]string{tmp}, "-C", tmp, "remote", "add", "origin", baseRepoPath); err != nil {
336 cleanup()
337 return "", nil, fmt.Errorf("git remote add: %s", strings.TrimSpace(string(stderr)))
338 }
339
340 if _, stderr, err := run([]string{tmp, baseRepoPath}, "-C", tmp, "fetch", "--no-tags", "origin", baseBranch+":base", baseBranch+":original_base"); err != nil {
341 cleanup()
342 return "", nil, fmt.Errorf("git fetch base branch %q: %s", baseBranch, strings.TrimSpace(string(stderr)))
343 }
344
345 if _, stderr, err := run([]string{tmp}, "-C", tmp, "symbolic-ref", "HEAD", "refs/heads/base"); err != nil {
346 cleanup()
347 return "", nil, fmt.Errorf("git symbolic-ref: %s", strings.TrimSpace(string(stderr)))
348 }
349
350 return tmp, cleanup, nil
351}
352
353func gitWithSandbox(ctx context.Context, sb sandbox.Backend, env []string, paths []string, args ...string) (stdout, stderr []byte, err error) {
354 cmd := exec.CommandContext(ctx, "git", args...)
355 var outBuf, errBuf bytes.Buffer
356 // set stdout/stderr/env before wrapping: the landlock backend copies these into a
357 // fresh *exec.Cmd, so mutating the original afterwards would be lost.
358 cmd.Stdout = &outBuf
359 cmd.Stderr = &errBuf
360 cmd.Env = env
361
362 if sb != nil {
363 wrapped, werr := sb.WrapMulti(paths, cmd)
364 if werr != nil {
365 return nil, nil, fmt.Errorf("sandbox wrap: %w", werr)
366 }
367 cmd = wrapped
368 } else if len(paths) > 0 {
369 cmd.Dir = paths[0]
370 }
371
372 err = cmd.Run()
373 return outBuf.Bytes(), errBuf.Bytes(), err
374}