forked from tangled.org/core
Monorepo for Tangled

knotserver: implement /merge and /merge/check endpoints

/:did/:repo/merge is only ever used when actually performing the merge,
so by this point all conflicts should've been rectified and the merge
*should* succeed. This is the appview's responsibility.

/:did/:repo/merge/check checks for mergeability, and can be used to
repeatedly check as the supplied patch is updated by the submitter.

authored by anirudh.fi and committed by oppi.li a4684f91 f4660ee8

Changed files
+323 -3
knotserver
types
+3 -3
knotserver/git.go
··· 25 25 } 26 26 27 27 if err := cmd.InfoRefs(); err != nil { 28 - http.Error(w, err.Error(), 500) 28 + writeError(w, err.Error(), 500) 29 29 d.l.Error("git: failed to execute git-upload-pack (info/refs)", "handler", "InfoRefs", "error", err) 30 30 return 31 31 } ··· 52 52 if r.Header.Get("Content-Encoding") == "gzip" { 53 53 reader, err := gzip.NewReader(r.Body) 54 54 if err != nil { 55 - http.Error(w, err.Error(), 500) 55 + writeError(w, err.Error(), 500) 56 56 d.l.Error("git: failed to create gzip reader", "handler", "UploadPack", "error", err) 57 57 return 58 58 } ··· 61 61 62 62 cmd.Stdin = reader 63 63 if err := cmd.UploadPack(); err != nil { 64 - http.Error(w, err.Error(), 500) 64 + writeError(w, err.Error(), 500) 65 65 d.l.Error("git: failed to execute git-upload-pack", "handler", "UploadPack", "error", err) 66 66 return 67 67 }
+210
knotserver/git/merge.go
··· 1 + package git 2 + 3 + import ( 4 + "bytes" 5 + "fmt" 6 + "os" 7 + "os/exec" 8 + "regexp" 9 + "strings" 10 + 11 + "github.com/go-git/go-git/v5" 12 + "github.com/go-git/go-git/v5/plumbing" 13 + ) 14 + 15 + type MergeError struct { 16 + Message string 17 + Conflicts []ConflictInfo 18 + HasConflict bool 19 + OtherError error 20 + } 21 + 22 + type ConflictInfo struct { 23 + Filename string 24 + Reason string 25 + } 26 + 27 + func (e MergeError) Error() string { 28 + if e.HasConflict { 29 + return fmt.Sprintf("merge failed due to conflicts: %s (%d conflicts)", e.Message, len(e.Conflicts)) 30 + } 31 + if e.OtherError != nil { 32 + return fmt.Sprintf("merge failed: %s: %v", e.Message, e.OtherError) 33 + } 34 + return fmt.Sprintf("merge failed: %s", e.Message) 35 + } 36 + 37 + func (g *GitRepo) createTempFileWithPatch(patchData []byte) (string, error) { 38 + tmpFile, err := os.CreateTemp("", "git-patch-*.patch") 39 + if err != nil { 40 + return "", fmt.Errorf("failed to create temporary patch file: %w", err) 41 + } 42 + 43 + if _, err := tmpFile.Write(patchData); err != nil { 44 + tmpFile.Close() 45 + os.Remove(tmpFile.Name()) 46 + return "", fmt.Errorf("failed to write patch data to temporary file: %w", err) 47 + } 48 + 49 + if err := tmpFile.Close(); err != nil { 50 + os.Remove(tmpFile.Name()) 51 + return "", fmt.Errorf("failed to close temporary patch file: %w", err) 52 + } 53 + 54 + return tmpFile.Name(), nil 55 + } 56 + 57 + func (g *GitRepo) cloneRepository(targetBranch string) (string, error) { 58 + tmpDir, err := os.MkdirTemp("", "git-clone-") 59 + if err != nil { 60 + return "", fmt.Errorf("failed to create temporary directory: %w", err) 61 + } 62 + 63 + _, err = git.PlainClone(tmpDir, false, &git.CloneOptions{ 64 + URL: "file://" + g.path, 65 + Depth: 1, 66 + SingleBranch: true, 67 + ReferenceName: plumbing.NewBranchReferenceName(targetBranch), 68 + }) 69 + if err != nil { 70 + os.RemoveAll(tmpDir) 71 + return "", fmt.Errorf("failed to clone repository: %w", err) 72 + } 73 + 74 + return tmpDir, nil 75 + } 76 + 77 + func (g *GitRepo) applyPatch(tmpDir, patchFile string, checkOnly bool) error { 78 + var stderr bytes.Buffer 79 + var cmd *exec.Cmd 80 + 81 + if checkOnly { 82 + cmd = exec.Command("git", "-C", tmpDir, "apply", "--check", "-v", patchFile) 83 + } else { 84 + exec.Command("git", "-C", tmpDir, "config", "advice.mergeConflict", "false").Run() 85 + cmd = exec.Command("git", "-C", tmpDir, "am", patchFile) 86 + } 87 + 88 + cmd.Stderr = &stderr 89 + 90 + if err := cmd.Run(); err != nil { 91 + if checkOnly { 92 + conflicts := parseGitApplyErrors(stderr.String()) 93 + return &MergeError{ 94 + Message: "patch cannot be applied cleanly", 95 + Conflicts: conflicts, 96 + HasConflict: len(conflicts) > 0, 97 + OtherError: err, 98 + } 99 + } 100 + return fmt.Errorf("patch application failed: %s", stderr.String()) 101 + } 102 + 103 + return nil 104 + } 105 + 106 + func (g *GitRepo) MergeCheck(patchData []byte, targetBranch string) error { 107 + patchFile, err := g.createTempFileWithPatch(patchData) 108 + if err != nil { 109 + return &MergeError{ 110 + Message: err.Error(), 111 + OtherError: err, 112 + } 113 + } 114 + defer os.Remove(patchFile) 115 + 116 + tmpDir, err := g.cloneRepository(targetBranch) 117 + if err != nil { 118 + return &MergeError{ 119 + Message: err.Error(), 120 + OtherError: err, 121 + } 122 + } 123 + defer os.RemoveAll(tmpDir) 124 + 125 + return g.applyPatch(tmpDir, patchFile, true) 126 + } 127 + 128 + func (g *GitRepo) Merge(patchData []byte, targetBranch string) error { 129 + patchFile, err := g.createTempFileWithPatch(patchData) 130 + if err != nil { 131 + return &MergeError{ 132 + Message: err.Error(), 133 + OtherError: err, 134 + } 135 + } 136 + defer os.Remove(patchFile) 137 + 138 + tmpDir, err := g.cloneRepository(targetBranch) 139 + if err != nil { 140 + return &MergeError{ 141 + Message: err.Error(), 142 + OtherError: err, 143 + } 144 + } 145 + defer os.RemoveAll(tmpDir) 146 + 147 + if err := g.applyPatch(tmpDir, patchFile, false); err != nil { 148 + return err 149 + } 150 + 151 + pushCmd := exec.Command("git", "-C", tmpDir, "push") 152 + if err := pushCmd.Run(); err != nil { 153 + return &MergeError{ 154 + Message: "failed to push changes to bare repository", 155 + OtherError: err, 156 + } 157 + } 158 + 159 + return nil 160 + } 161 + 162 + func parseGitApplyErrors(errorOutput string) []ConflictInfo { 163 + var conflicts []ConflictInfo 164 + lines := strings.Split(errorOutput, "\n") 165 + 166 + var currentFile string 167 + 168 + for i := range lines { 169 + line := strings.TrimSpace(lines[i]) 170 + 171 + if strings.HasPrefix(line, "error: patch failed:") { 172 + parts := strings.SplitN(line, ":", 3) 173 + if len(parts) >= 3 { 174 + currentFile = strings.TrimSpace(parts[2]) 175 + } 176 + continue 177 + } 178 + 179 + if match := regexp.MustCompile(`^error: (.*):(\d+): (.*)$`).FindStringSubmatch(line); len(match) >= 4 { 180 + if currentFile == "" { 181 + currentFile = match[1] 182 + } 183 + 184 + conflicts = append(conflicts, ConflictInfo{ 185 + Filename: currentFile, 186 + Reason: match[3], 187 + }) 188 + continue 189 + } 190 + 191 + if strings.Contains(line, "already exists in working directory") { 192 + conflicts = append(conflicts, ConflictInfo{ 193 + Filename: currentFile, 194 + Reason: "file already exists", 195 + }) 196 + } else if strings.Contains(line, "does not exist in working tree") { 197 + conflicts = append(conflicts, ConflictInfo{ 198 + Filename: currentFile, 199 + Reason: "file does not exist", 200 + }) 201 + } else if strings.Contains(line, "patch does not apply") { 202 + conflicts = append(conflicts, ConflictInfo{ 203 + Filename: currentFile, 204 + Reason: "patch does not apply", 205 + }) 206 + } 207 + } 208 + 209 + return conflicts 210 + }
+5
knotserver/handler.go
··· 75 75 r.Get("/info/refs", h.InfoRefs) 76 76 r.Post("/git-upload-pack", h.UploadPack) 77 77 78 + r.Route("/merge", func(r chi.Router) { 79 + r.Post("/", h.Merge) 80 + r.Post("/check", h.MergeCheck) 81 + }) 82 + 78 83 r.Route("/tree/{ref}", func(r chi.Router) { 79 84 r.Get("/", h.RepoIndex) 80 85 r.Get("/*", h.RepoTree)
+6
knotserver/http_util.go
··· 24 24 func writeMsg(w http.ResponseWriter, msg string) { 25 25 writeJSON(w, map[string]string{"msg": msg}) 26 26 } 27 + 28 + func writeConflict(w http.ResponseWriter, data interface{}) { 29 + w.Header().Set("Content-Type", "application/json") 30 + w.WriteHeader(http.StatusConflict) 31 + json.NewEncoder(w).Encode(data) 32 + }
+98
knotserver/routes.go
··· 554 554 } 555 555 556 556 w.WriteHeader(http.StatusNoContent) 557 + 558 + } 559 + func (h *Handle) Merge(w http.ResponseWriter, r *http.Request) { 560 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 561 + 562 + var data struct { 563 + Patch string `json:"patch"` 564 + Branch string `json:"branch"` 565 + } 566 + 567 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 568 + writeError(w, err.Error(), http.StatusBadRequest) 569 + h.l.Error("git: failed to unmarshal json patch", "handler", "Merge", "error", err) 570 + return 571 + } 572 + 573 + patch := data.Patch 574 + branch := data.Branch 575 + gr, err := git.Open(path, branch) 576 + if err != nil { 577 + notFound(w) 578 + return 579 + } 580 + 581 + if err := gr.Merge([]byte(patch), branch); err != nil { 582 + var mergeErr *git.MergeError 583 + if errors.As(err, &mergeErr) { 584 + conflictDetails := make([]map[string]interface{}, len(mergeErr.Conflicts)) 585 + for i, conflict := range mergeErr.Conflicts { 586 + conflictDetails[i] = map[string]interface{}{ 587 + "filename": conflict.Filename, 588 + "reason": conflict.Reason, 589 + } 590 + } 591 + response := map[string]interface{}{ 592 + "message": mergeErr.Message, 593 + "conflicts": conflictDetails, 594 + } 595 + writeConflict(w, response) 596 + h.l.Error("git: merge conflict", "handler", "Merge", "error", mergeErr) 597 + } else { 598 + writeError(w, err.Error(), http.StatusBadRequest) 599 + h.l.Error("git: failed to merge", "handler", "Merge", "error", err.Error()) 600 + } 601 + return 602 + } 603 + 604 + w.WriteHeader(http.StatusOK) 605 + } 606 + 607 + func (h *Handle) MergeCheck(w http.ResponseWriter, r *http.Request) { 608 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 609 + 610 + var data struct { 611 + Patch string `json:"patch"` 612 + Branch string `json:"branch"` 613 + } 614 + 615 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 616 + writeError(w, err.Error(), http.StatusBadRequest) 617 + h.l.Error("git: failed to unmarshal json patch", "handler", "MergeCheck", "error", err) 618 + return 619 + } 620 + 621 + patch := data.Patch 622 + branch := data.Branch 623 + gr, err := git.Open(path, branch) 624 + if err != nil { 625 + notFound(w) 626 + return 627 + } 628 + 629 + err = gr.MergeCheck([]byte(patch), branch) 630 + if err == nil { 631 + w.WriteHeader(http.StatusOK) 632 + return 633 + } 634 + 635 + var mergeErr *git.MergeError 636 + if errors.As(err, &mergeErr) { 637 + conflictDetails := make([]map[string]interface{}, len(mergeErr.Conflicts)) 638 + for i, conflict := range mergeErr.Conflicts { 639 + conflictDetails[i] = map[string]interface{}{ 640 + "filename": conflict.Filename, 641 + "reason": conflict.Reason, 642 + } 643 + } 644 + response := map[string]interface{}{ 645 + "message": mergeErr.Message, 646 + "conflicts": conflictDetails, 647 + } 648 + writeConflict(w, response) 649 + h.l.Error("git: merge conflict", "handler", "MergeCheck", "error", mergeErr.Error()) 650 + return 651 + } 652 + 653 + writeError(w, err.Error(), http.StatusInternalServerError) 654 + h.l.Error("git: failed to check merge", "handler", "MergeCheck", "error", err.Error()) 557 655 } 558 656 559 657 func (h *Handle) AddMember(w http.ResponseWriter, r *http.Request) {
+1
types/merge.go
··· 1 + package types