This repository has no description
0

Configure Feed

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

knotserver/xrpc: `git.mergeCheck` and `git.mergeCommit`

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

author
Seongmin Lee
date (Jul 26, 2026, 3:18 AM +0900) commit 60735181 parent a86f427a change-id wlnwlotn
+601
+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 + }
+374
knotserver/xrpc/git_merge_commit.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) 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 + 57 + func (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". 223 + func 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. 241 + func 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>". 254 + func 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... 272 + func 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. 302 + func 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 + 353 + func 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 + }
+2
knotserver/xrpc/xrpc.go
··· 50 50 r.Use(x.ServiceAuth.VerifyServiceAuth) 51 51 52 52 r.Post("/"+tangled.GitKeepCommitNSID, x.KeepCommit) 53 + r.Post("/"+tangled.GitMergeCommitNSID, x.MergeCommit) 53 54 r.Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch) 54 55 r.Post("/"+tangled.RepoDeleteBranchNSID, x.DeleteBranch) 55 56 r.Post("/"+tangled.RepoCreateNSID, x.CreateRepo) ··· 70 71 // - we can calculate on PR submit/resubmit/gitRefUpdate etc. 71 72 // - use ETags on clients to keep requests to a minimum 72 73 r.Post("/"+tangled.RepoMergeCheckNSID, x.MergeCheck) 74 + r.Post("/"+tangled.GitMergeCheckNSID, x.GitMergeCheck) 73 75 74 76 // repo query endpoints (no auth required) 75 77 r.Get("/"+tangled.RepoTreeNSID, x.RepoTree)