package xrpc import ( "bytes" "context" "encoding/json" "fmt" "log/slog" "net/http" "net/url" "os" "os/exec" "path/filepath" "strings" "github.com/bluesky-social/indigo/atproto/atclient" "github.com/bluesky-social/indigo/atproto/syntax" "tangled.org/core/api/tangled" "tangled.org/core/knotserver/sandbox" "tangled.org/core/rbac" ) func (x *Xrpc) MergeCommit(w http.ResponseWriter, r *http.Request) { var input tangled.GitMergeCommit_Input if err := json.NewDecoder(r.Body).Decode(&input); err != nil { writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: "failed to decode json body"}) return } l := x.Logger.With("handler", "MergeCommit", "input", input) l.Debug("request") if err := gitMergeCommit_Input_Validate(input); err != nil { writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: err.Error()}) return } actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) if !ok { writeJson(w, http.StatusUnauthorized, &atclient.ErrorBody{Name: "Unauthorized", Message: "missing actor DID"}) return } if allowed, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, input.Target.Repo); err != nil || !allowed { writeJson(w, http.StatusUnauthorized, &atclient.ErrorBody{Name: "Forbidden", Message: fmt.Sprintf("%s is not allowed to merge into this repository", actorDid.String())}) return } output, status, apierr := x.mergeCommit(r.Context(), input) if apierr != nil { l.Error("failed", "kind", apierr.Name, "error", apierr.Message) writeJson(w, status, apierr) return } writeJson(w, status, output) } func (x *Xrpc) mergeCommit(ctx context.Context, input tangled.GitMergeCommit_Input) (any, int, *atclient.ErrorBody) { l := x.Logger.With("handler", "mergePullRequest") fail := func(status int, name, clientMsg string, detail ...any) (any, int, *atclient.ErrorBody) { l.Error(clientMsg, append([]any{"name", name}, detail...)...) return nil, status, &atclient.ErrorBody{Name: name, Message: clientMsg} } baseRepoPath, _, _, err := x.Db.ResolveRepoDIDOnDisk(x.Config.Repo.ScanPath, input.Target.Repo) if err != nil { return fail(http.StatusNotFound, "RepoNotFound", "unknown repository", "repo", input.Target.Repo, "err", err) } sourceRepoDid := syntax.DID(input.Source.Repo) var sourceRepoUrl string var sourceRepoPath string // non-empty only when the source is local to this knot if p, _, _, err := x.Db.ResolveRepoDIDOnDisk(x.Config.Repo.ScanPath, input.Source.Repo); err == nil { sourceRepoPath = p sourceRepoUrl = "file://" + p } else { ident, err := x.Resolver.Directory().LookupDID(ctx, sourceRepoDid) if err != nil { return fail(http.StatusNotFound, "RepoNotFound", "unknown repository", "source", sourceRepoDid, "err", err) } sourceKnot := ident.GetServiceEndpoint("atproto_pds") u, err := url.Parse(sourceKnot) if err != nil { return fail(http.StatusNotFound, "RepoNotFound", "unknown repository", "source", sourceRepoDid, "knot", sourceKnot, "err", err) } sourceRepoUrl = u.JoinPath(sourceRepoDid.String()).String() } var authorName, authorEmail, authorDate string if input.MergeCommit != nil && input.MergeCommit.Author != nil { authorName = input.MergeCommit.Author.Name authorEmail = input.MergeCommit.Author.Email authorDate = input.MergeCommit.Author.When } else { authorName = x.Config.Git.UserName authorEmail = x.Config.Git.UserEmail } var message string if input.MergeCommit != nil && input.MergeCommit.Message != nil { message = *input.MergeCommit.Message } else { shortSha := input.Source.Commit if len(shortSha) > 8 { shortSha = shortSha[:8] } message = fmt.Sprintf("Merge %s into %s", shortSha, input.Target.Branch) } env := append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_AUTHOR_NAME="+authorName, "GIT_AUTHOR_EMAIL="+authorEmail, "GIT_COMMITTER_NAME="+x.Config.Git.UserName, "GIT_COMMITTER_EMAIL="+x.Config.Git.UserEmail, ) if authorDate != "" { env = append(env, "GIT_AUTHOR_DATE="+authorDate) } // 1. create temp repo with git alternate tmpRepoPath, cleanup, err := createTemporaryRepoForMerge(ctx, x.Sandbox, baseRepoPath, input.Target.Branch) if err != nil { return fail(http.StatusInternalServerError, "InternalError", "failed to prepare merge", "err", err) } defer cleanup() runGit := func(args ...string) ([]byte, []byte, error) { args = append([]string{"-C", tmpRepoPath}, args...) return gitWithSandbox(ctx, x.Sandbox, env, []string{tmpRepoPath}, args...) } // 2. fetch source as "tracking" branch fetchPaths := []string{tmpRepoPath} if sourceRepoPath != "" { fetchPaths = append(fetchPaths, sourceRepoPath) } if _, stderr, err := gitWithSandbox(ctx, x.Sandbox, env, fetchPaths, "-C", tmpRepoPath, "fetch", sourceRepoUrl, input.Source.Commit); err != nil { return fail(http.StatusInternalServerError, "InternalError", "failed to fetch source commit", "err", err, "stderr", strings.TrimSpace(string(stderr))) } if _, stderr, err := runGit("branch", "tracking", input.Source.Commit); err != nil { return fail(http.StatusBadRequest, "InvalidCommit", "source commit unavailable", "commit", input.Source.Commit, "err", err, "stderr", strings.TrimSpace(string(stderr))) } // populate the working tree on the base branch. if _, stderr, err := runGit("checkout", "-f", "base"); err != nil { return fail(http.StatusInternalServerError, "InternalError", "failed to merge commit", "step", "checkout base", "err", err, "stderr", strings.TrimSpace(string(stderr))) } // 3. merge switch input.Style { case "rebase": if status, apierr := rebaseTrackingOntoBase(runGit, l, tmpRepoPath); apierr != nil { return nil, status, apierr } if _, stderr, err := runGit("checkout", "base"); err != nil { return fail(http.StatusInternalServerError, "InternalError", "failed to merge commit", "step", "checkout base after rebase", "err", err, "stderr", strings.TrimSpace(string(stderr))) } if _, stderr, err := runGit("merge", "--ff-only", "staging"); err != nil { return mergeConflictError(runGit, l, stderr) } case "merge": if _, stderr, err := runGit("merge", "--no-ff", "--no-commit", "tracking"); err != nil { return mergeConflictError(runGit, l, stderr) } if _, stderr, err := runGit("commit", "--no-gpg-sign", "--message="+message); err != nil { return fail(http.StatusInternalServerError, "InternalError", "failed to merge commit", "step", "merge commit", "err", err, "stderr", strings.TrimSpace(string(stderr))) } case "rebase-merge": if status, apierr := rebaseTrackingOntoBase(runGit, l, tmpRepoPath); apierr != nil { return nil, status, apierr } if _, stderr, err := runGit("checkout", "base"); err != nil { return fail(http.StatusInternalServerError, "InternalError", "failed to merge commit", "step", "checkout base after rebase", "err", err, "stderr", strings.TrimSpace(string(stderr))) } if _, stderr, err := runGit("merge", "--no-ff", "--no-commit", "staging"); err != nil { return mergeConflictError(runGit, l, stderr) } if _, stderr, err := runGit("commit", "--no-gpg-sign", "--message="+message); err != nil { return fail(http.StatusInternalServerError, "InternalError", "failed to merge commit", "step", "merge commit", "err", err, "stderr", strings.TrimSpace(string(stderr))) } case "squash-rebase": if _, stderr, err := runGit("merge", "--squash", "tracking"); err != nil { return mergeConflictError(runGit, l, stderr) } if _, stderr, err := runGit("commit", "--no-gpg-sign", "--message="+message); err != nil { return fail(http.StatusInternalServerError, "InternalError", "failed to merge commit", "step", "squash commit", "err", err, "stderr", strings.TrimSpace(string(stderr))) } case "squash-merge": // TODO: implement this return nil, http.StatusBadRequest, &atclient.ErrorBody{Name: "BadRequest", Message: fmt.Sprintf("unknown merge style: %q", input.Style)} case "squash-rebase-merge": return fail(http.StatusInternalServerError, "InternalError", "squash-rebase-merge is not yet supported") case "fast-forward-only": if _, stderr, err := runGit("merge", "--ff-only", "tracking"); err != nil { 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))) } default: return nil, http.StatusBadRequest, &atclient.ErrorBody{Name: "BadRequest", Message: fmt.Sprintf("unknown merge style: %q", input.Style)} } // 4. push the merged "base" back to the real base repo's branch. this goes through // receive-pack so the base repo's hooks fire (ref-update notification). if _, stderr, err := gitWithSandbox(ctx, x.Sandbox, env, []string{tmpRepoPath, baseRepoPath}, "-C", tmpRepoPath, "push", "origin", "base:refs/heads/"+input.Target.Branch); err != nil { msg := strings.TrimSpace(string(stderr)) if strings.Contains(msg, "non-fast-forward") || strings.Contains(msg, "rejected") { return fail(http.StatusConflict, "PushRejected", "target branch changed; retry the merge", "branch", input.Target.Branch, "err", err, "stderr", msg) } return fail(http.StatusInternalServerError, "InternalError", "failed to complete merge", "step", "push", "err", err, "stderr", msg) } return nil, http.StatusOK, nil } // rebaseTrackingOntoBase checks out "tracking" as "staging" and rebases it onto "base". func rebaseTrackingOntoBase(run func(...string) ([]byte, []byte, error), l *slog.Logger, tmpRepoPath string) (int, *atclient.ErrorBody) { if _, stderr, err := run("checkout", "-b", "staging", "tracking"); err != nil { l.Error("failed to merge commit", "step", "checkout staging", "err", err, "stderr", strings.TrimSpace(string(stderr))) return http.StatusInternalServerError, &atclient.ErrorBody{Name: "InternalError", Message: "failed to merge commit"} } if _, stderr, err := run("rebase", "base"); err != nil { if _, statErr := os.Stat(filepath.Join(tmpRepoPath, ".git", "REBASE_HEAD")); statErr == nil { l.Error("rebase produced conflicts", "err", err, "stderr", strings.TrimSpace(string(stderr))) return http.StatusConflict, &atclient.ErrorBody{Name: "MergeConflict", Message: "rebase produced conflicts"} } l.Error("failed to merge commit", "step", "rebase", "err", err, "stderr", strings.TrimSpace(string(stderr))) return http.StatusInternalServerError, &atclient.ErrorBody{Name: "InternalError", Message: "failed to merge commit"} } return 0, nil } // mergeConflictError inspects the temp repo for unmerged paths after a failed merge and // returns a 409 listing them, falling back to a 500 when the failure is not a conflict. func mergeConflictError(runGit func(...string) ([]byte, []byte, error), l *slog.Logger, mergeStderr []byte) (any, int, *atclient.ErrorBody) { stdout, _, _ := runGit("ls-files", "--unmerged") files := parseUnmergedFiles(stdout) if len(files) > 0 { l.Error("merge produced conflicts", "files", files, "stderr", strings.TrimSpace(string(mergeStderr))) return nil, http.StatusConflict, &atclient.ErrorBody{Name: "MergeConflict", Message: "merge produced conflicts"} } l.Error("failed to merge commit", "step", "merge", "stderr", strings.TrimSpace(string(mergeStderr))) return nil, http.StatusInternalServerError, &atclient.ErrorBody{Name: "InternalError", Message: "failed to merge commit"} } // parseUnmergedFiles parses `git ls-files --unmerged` output into a deduplicated list of paths. // Each line looks like: " \t". func parseUnmergedFiles(out []byte) []string { seen := make(map[string]struct{}) var files []string for line := range strings.SplitSeq(string(out), "\n") { _, path, ok := strings.Cut(line, "\t") if !ok { continue } if _, ok := seen[path]; ok { continue } seen[path] = struct{}{} files = append(files, path) } return files } // lexgen doesn't give Validate() method... func gitMergeCommit_Input_Validate(input tangled.GitMergeCommit_Input) error { if input.Target == nil { return fmt.Errorf("target: required") } if input.Source == nil { return fmt.Errorf("source: required") } if _, err := syntax.ParseDID(input.Target.Repo); err != nil { return fmt.Errorf("target.repo: invalid DID: %w", err) } if input.Target.Branch == "" { return fmt.Errorf("target.branch: required") } if _, err := syntax.ParseDID(input.Source.Repo); err != nil { return fmt.Errorf("source.repo: invalid DID: %w", err) } if input.Source.Commit == "" { return fmt.Errorf("source.commit: required") } switch input.Style { case "merge", "rebase", "rebase-merge", "squash", "fast-forward-only": default: return fmt.Errorf("style: unknown merge style %q", input.Style) } return nil } // createTemporaryRepoForMerge creates a temporary non-bare repo with the base repo's // "base" branch (and a copy "original_base") checked out via a git alternate to the base // repo's object store. Returns the temp repo path and a cleanup func that removes it. func createTemporaryRepoForMerge(ctx context.Context, sb sandbox.Backend, baseRepoPath string, baseBranch string) (path string, cleanup context.CancelFunc, err error) { tmp, err := os.MkdirTemp("", "merge-*") if err != nil { return "", nil, fmt.Errorf("create temp dir: %w", err) } cleanup = func() { os.RemoveAll(tmp) } env := append(os.Environ(), "GIT_TERMINAL_PROMPT=0") run := func(paths []string, args ...string) ([]byte, []byte, error) { return gitWithSandbox(ctx, sb, env, paths, args...) } // git init needs a working dir (non-bare). if _, stderr, err := run([]string{tmp}, "-C", tmp, "init"); err != nil { cleanup() return "", nil, fmt.Errorf("git init: %s", strings.TrimSpace(string(stderr))) } // borrow the base repo's objects via alternates. if err := func(repoPath, srcRepoPath string) error { p := filepath.Join(repoPath, ".git", "objects", "info", "alternates") f, err := os.OpenFile(p, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) if err != nil { return err } defer f.Close() _, err = fmt.Fprintln(f, filepath.Join(srcRepoPath, "objects")) return err }(tmp, baseRepoPath); err != nil { cleanup() return "", nil, fmt.Errorf("add base objects: %w", err) } if _, stderr, err := run([]string{tmp}, "-C", tmp, "remote", "add", "origin", baseRepoPath); err != nil { cleanup() return "", nil, fmt.Errorf("git remote add: %s", strings.TrimSpace(string(stderr))) } if _, stderr, err := run([]string{tmp, baseRepoPath}, "-C", tmp, "fetch", "--no-tags", "origin", baseBranch+":base", baseBranch+":original_base"); err != nil { cleanup() return "", nil, fmt.Errorf("git fetch base branch %q: %s", baseBranch, strings.TrimSpace(string(stderr))) } if _, stderr, err := run([]string{tmp}, "-C", tmp, "symbolic-ref", "HEAD", "refs/heads/base"); err != nil { cleanup() return "", nil, fmt.Errorf("git symbolic-ref: %s", strings.TrimSpace(string(stderr))) } return tmp, cleanup, nil } func gitWithSandbox(ctx context.Context, sb sandbox.Backend, env []string, paths []string, args ...string) (stdout, stderr []byte, err error) { cmd := exec.CommandContext(ctx, "git", args...) var outBuf, errBuf bytes.Buffer // set stdout/stderr/env before wrapping: the landlock backend copies these into a // fresh *exec.Cmd, so mutating the original afterwards would be lost. cmd.Stdout = &outBuf cmd.Stderr = &errBuf cmd.Env = env if sb != nil { wrapped, werr := sb.WrapMulti(paths, cmd) if werr != nil { return nil, nil, fmt.Errorf("sandbox wrap: %w", werr) } cmd = wrapped } else if len(paths) > 0 { cmd.Dir = paths[0] } err = cmd.Run() return outBuf.Bytes(), errBuf.Bytes(), err }