Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
16 kB · 374 lines
Go
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375package 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: "<mode> <sha> <stage>\t<path>".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}