forked from tangled.org/core
Monorepo for Tangled — https://tangled.org

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.

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