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_commit.go
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}