From 60735181fdad1681d73c03439782209c732d047e Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Mon, 6 Jul 2026 18:47:57 +0900 Subject: [PATCH] knotserver/xrpc: `git.mergeCheck` and `git.mergeCommit` Signed-off-by: Seongmin Lee --- knotserver/xrpc/git_merge_check.go | 225 +++++++++++++++++ knotserver/xrpc/git_merge_commit.go | 374 ++++++++++++++++++++++++++++ knotserver/xrpc/xrpc.go | 2 + 3 files changed, 601 insertions(+) create mode 100644 knotserver/xrpc/git_merge_check.go create mode 100644 knotserver/xrpc/git_merge_commit.go diff --git a/knotserver/xrpc/git_merge_check.go b/knotserver/xrpc/git_merge_check.go new file mode 100644 index 00000000..99b205a6 --- /dev/null +++ b/knotserver/xrpc/git_merge_check.go @@ -0,0 +1,225 @@ +package xrpc + +import ( + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "net/http" + "net/url" + "os" + "strings" + + "github.com/bluesky-social/indigo/atproto/atclient" + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/dgraph-io/ristretto" + "tangled.org/core/api/tangled" +) + +type MergeInput struct { + TargetRepo syntax.DID + TargetBranch string + SourceRepo syntax.DID + SourceCommit string +} + +type MergeCheckCache struct { + cache *ristretto.Cache +} + +func (m *MergeCheckCache) cacheKey(input MergeInput) string { + raw := strings.Join([]string{ + input.TargetRepo.String(), + input.TargetBranch, + input.SourceRepo.String(), + input.SourceCommit, + }, "\x00") + sum := sha256.Sum256([]byte(raw)) + return fmt.Sprintf("%x", sum) +} + +func (m *MergeCheckCache) cacheVal(out *tangled.GitMergeCheck_Output) any { + return *out +} + +func (m *MergeCheckCache) Set(input MergeInput, mergeCheck *tangled.GitMergeCheck_Output) { + key := m.cacheKey(input) + val := m.cacheVal(mergeCheck) + m.cache.Set(key, val, 0) +} + +func (m *MergeCheckCache) Get(input MergeInput) (tangled.GitMergeCheck_Output, bool) { + key := m.cacheKey(input) + if val, ok := m.cache.Get(key); ok { + if out, ok := val.(tangled.GitMergeCheck_Output); ok { + // cache hit + return out, true + } + } + + // cache miss + return tangled.GitMergeCheck_Output{}, false +} + +var mergeCheckCache MergeCheckCache + +func init() { + cache, _ := ristretto.NewCache(&ristretto.Config{ + NumCounters: 1e7, + MaxCost: 1 << 30, + BufferItems: 64, + TtlTickerDurationInSec: 60 * 60 * 24 * 2, // 2 days + }) + mergeCheckCache = MergeCheckCache{cache} +} + +func (x *Xrpc) GitMergeCheck(w http.ResponseWriter, r *http.Request) { + var input tangled.GitMergeCheck_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", "MergeCheck2", "input", input) + l.Debug("request") + + if err := gitMergeCheck_Input_Validate(input); err != nil { + writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "InvalidRequest", Message: err.Error()}) + return + } + + mergeInput := MergeInput{ + TargetRepo: syntax.DID(input.Repo), + TargetBranch: input.Branch, + SourceRepo: syntax.DID(input.Source.Repo), + SourceCommit: input.Source.Commit, + } + + // check cache + if cached, ok := mergeCheckCache.Get(mergeInput); ok { + l.Debug("cache hit") + writeJson(w, http.StatusOK, cached) + return + } + + output, status, apierr := x.mergeCheck(r.Context(), input) + if apierr != nil { + l.Error("failed", "kind", apierr.Name, "error", apierr.Message) + writeJson(w, status, apierr) + return + } + + // update cache + mergeCheckCache.Set(mergeInput, &output) + + writeJson(w, status, output) +} + +func (x *Xrpc) mergeCheck(ctx context.Context, input tangled.GitMergeCheck_Input) (tangled.GitMergeCheck_Output, int, *atclient.ErrorBody) { + l := x.Logger.With("handler", "mergeCheck") + + fail := func(status int, name, clientMsg string, detail ...any) (tangled.GitMergeCheck_Output, int, *atclient.ErrorBody) { + l.Error(clientMsg, append([]any{"name", name}, detail...)...) + return tangled.GitMergeCheck_Output{}, status, &atclient.ErrorBody{Name: name, Message: clientMsg} + } + + baseRepoPath, _, _, err := x.Db.ResolveRepoDIDOnDisk(x.Config.Repo.ScanPath, input.Repo) + if err != nil { + return fail(http.StatusNotFound, "RepoNotFound", "unknown repository", "repo", input.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() + } + + env := append(os.Environ(), "GIT_TERMINAL_PROMPT=0") + + // 1. create temp repo with git alternate to the base repo's objects. + tmpRepoPath, cleanup, err := createTemporaryRepoForMerge(ctx, x.Sandbox, baseRepoPath, input.Branch) + if err != nil { + return fail(http.StatusInternalServerError, "InternalError", "failed to prepare merge check", "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 commit and pin it to a "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.StatusNotFound, "CommitNotFound", "source commit unavailable", "commit", input.Source.Commit, "err", err, "stderr", strings.TrimSpace(string(stderr))) + } + if _, stderr, err := runGit("branch", "tracking", input.Source.Commit); err != nil { + return fail(http.StatusNotFound, "CommitNotFound", "source commit unavailable", "commit", input.Source.Commit, "err", err, "stderr", strings.TrimSpace(string(stderr))) + } + + // 3. populate the working tree on the base branch. + if _, stderr, err := runGit("checkout", "-f", "base"); err != nil { + return fail(http.StatusInternalServerError, "InternalError", "failed to perform merge check", "step", "checkout base", "err", err, "stderr", strings.TrimSpace(string(stderr))) + } + + // 4. attempt a 3-way merge without committing. + if _, stderr, err := runGit("merge", "--no-commit", "--no-ff", "tracking"); err != nil { + lsOut, _, _ := runGit("ls-files", "--unmerged") + files := parseUnmergedFiles(lsOut) + if len(files) == 0 { + return fail(http.StatusInternalServerError, "InternalError", "failed to perform merge check", "step", "merge", "err", err, "stderr", strings.TrimSpace(string(stderr))) + } + + conflicts := make([]*tangled.GitMergeCheck_ConflictInfo, 0, len(files)) + for _, f := range files { + conflicts = append(conflicts, &tangled.GitMergeCheck_ConflictInfo{ + Filename: f, + Reason: "merge conflict", + }) + } + msg := strings.TrimSpace(string(stderr)) + l.Debug("merge check found conflicts", "files", files) + return tangled.GitMergeCheck_Output{ + IsConflicted: true, + Conflicts: conflicts, + Message: &msg, + }, http.StatusOK, nil + } + + return tangled.GitMergeCheck_Output{IsConflicted: false}, http.StatusOK, nil +} + +// lexgen doesn't give Validate() method... +func gitMergeCheck_Input_Validate(input tangled.GitMergeCheck_Input) error { + if _, err := syntax.ParseDID(input.Repo); err != nil { + return fmt.Errorf("repo: invalid DID: %w", err) + } + if input.Branch == "" { + return fmt.Errorf("branch: required") + } + if input.Source == nil { + return fmt.Errorf("source: 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") + } + return nil +} diff --git a/knotserver/xrpc/git_merge_commit.go b/knotserver/xrpc/git_merge_commit.go new file mode 100644 index 00000000..4ef2f5b8 --- /dev/null +++ b/knotserver/xrpc/git_merge_commit.go @@ -0,0 +1,374 @@ +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 +} diff --git a/knotserver/xrpc/xrpc.go b/knotserver/xrpc/xrpc.go index 7f9e7216..47601695 100644 --- a/knotserver/xrpc/xrpc.go +++ b/knotserver/xrpc/xrpc.go @@ -50,6 +50,7 @@ func (x *Xrpc) Router() http.Handler { r.Use(x.ServiceAuth.VerifyServiceAuth) r.Post("/"+tangled.GitKeepCommitNSID, x.KeepCommit) + r.Post("/"+tangled.GitMergeCommitNSID, x.MergeCommit) r.Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch) r.Post("/"+tangled.RepoDeleteBranchNSID, x.DeleteBranch) r.Post("/"+tangled.RepoCreateNSID, x.CreateRepo) @@ -70,6 +71,7 @@ func (x *Xrpc) Router() http.Handler { // - we can calculate on PR submit/resubmit/gitRefUpdate etc. // - use ETags on clients to keep requests to a minimum r.Post("/"+tangled.RepoMergeCheckNSID, x.MergeCheck) + r.Post("/"+tangled.GitMergeCheckNSID, x.GitMergeCheck) // repo query endpoints (no auth required) r.Get("/"+tangled.RepoTreeNSID, x.RepoTree) -- 2.51.2