Monorepo for Tangled
0

Configure Feed

Select the types of activity you want to include in your feed.

knotserver/xrpc: `git.mergeCheck` and `repo.mergePullRequest`

Signed-off-by: Seongmin Lee <git@boltless.me>

Seongmin Lee (Jul 14, 2026, 1:55 AM +0900) 345e3b94 c083a498

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