forked from tangled.org/core
this repo has no description

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
···
}
if err := cmd.InfoRefs(); err != nil {
-
http.Error(w, err.Error(), 500)
+
writeError(w, err.Error(), 500)
d.l.Error("git: failed to execute git-upload-pack (info/refs)", "handler", "InfoRefs", "error", err)
return
}
···
if r.Header.Get("Content-Encoding") == "gzip" {
reader, err := gzip.NewReader(r.Body)
if err != nil {
-
http.Error(w, err.Error(), 500)
+
writeError(w, err.Error(), 500)
d.l.Error("git: failed to create gzip reader", "handler", "UploadPack", "error", err)
return
}
···
cmd.Stdin = reader
if err := cmd.UploadPack(); err != nil {
-
http.Error(w, err.Error(), 500)
+
writeError(w, err.Error(), 500)
d.l.Error("git: failed to execute git-upload-pack", "handler", "UploadPack", "error", err)
return
}
+210
knotserver/git/merge.go
···
+
package git
+
+
import (
+
"bytes"
+
"fmt"
+
"os"
+
"os/exec"
+
"regexp"
+
"strings"
+
+
"github.com/go-git/go-git/v5"
+
"github.com/go-git/go-git/v5/plumbing"
+
)
+
+
type MergeError struct {
+
Message string
+
Conflicts []ConflictInfo
+
HasConflict bool
+
OtherError error
+
}
+
+
type ConflictInfo struct {
+
Filename string
+
Reason string
+
}
+
+
func (e MergeError) Error() string {
+
if e.HasConflict {
+
return fmt.Sprintf("merge failed due to conflicts: %s (%d conflicts)", e.Message, len(e.Conflicts))
+
}
+
if e.OtherError != nil {
+
return fmt.Sprintf("merge failed: %s: %v", e.Message, e.OtherError)
+
}
+
return fmt.Sprintf("merge failed: %s", e.Message)
+
}
+
+
func (g *GitRepo) createTempFileWithPatch(patchData []byte) (string, error) {
+
tmpFile, err := os.CreateTemp("", "git-patch-*.patch")
+
if err != nil {
+
return "", fmt.Errorf("failed to create temporary patch file: %w", err)
+
}
+
+
if _, err := tmpFile.Write(patchData); err != nil {
+
tmpFile.Close()
+
os.Remove(tmpFile.Name())
+
return "", fmt.Errorf("failed to write patch data to temporary file: %w", err)
+
}
+
+
if err := tmpFile.Close(); err != nil {
+
os.Remove(tmpFile.Name())
+
return "", fmt.Errorf("failed to close temporary patch file: %w", err)
+
}
+
+
return tmpFile.Name(), nil
+
}
+
+
func (g *GitRepo) cloneRepository(targetBranch string) (string, error) {
+
tmpDir, err := os.MkdirTemp("", "git-clone-")
+
if err != nil {
+
return "", fmt.Errorf("failed to create temporary directory: %w", err)
+
}
+
+
_, err = git.PlainClone(tmpDir, false, &git.CloneOptions{
+
URL: "file://" + g.path,
+
Depth: 1,
+
SingleBranch: true,
+
ReferenceName: plumbing.NewBranchReferenceName(targetBranch),
+
})
+
if err != nil {
+
os.RemoveAll(tmpDir)
+
return "", fmt.Errorf("failed to clone repository: %w", err)
+
}
+
+
return tmpDir, nil
+
}
+
+
func (g *GitRepo) applyPatch(tmpDir, patchFile string, checkOnly bool) error {
+
var stderr bytes.Buffer
+
var cmd *exec.Cmd
+
+
if checkOnly {
+
cmd = exec.Command("git", "-C", tmpDir, "apply", "--check", "-v", patchFile)
+
} else {
+
exec.Command("git", "-C", tmpDir, "config", "advice.mergeConflict", "false").Run()
+
cmd = exec.Command("git", "-C", tmpDir, "am", patchFile)
+
}
+
+
cmd.Stderr = &stderr
+
+
if err := cmd.Run(); err != nil {
+
if checkOnly {
+
conflicts := parseGitApplyErrors(stderr.String())
+
return &MergeError{
+
Message: "patch cannot be applied cleanly",
+
Conflicts: conflicts,
+
HasConflict: len(conflicts) > 0,
+
OtherError: err,
+
}
+
}
+
return fmt.Errorf("patch application failed: %s", stderr.String())
+
}
+
+
return nil
+
}
+
+
func (g *GitRepo) MergeCheck(patchData []byte, targetBranch string) error {
+
patchFile, err := g.createTempFileWithPatch(patchData)
+
if err != nil {
+
return &MergeError{
+
Message: err.Error(),
+
OtherError: err,
+
}
+
}
+
defer os.Remove(patchFile)
+
+
tmpDir, err := g.cloneRepository(targetBranch)
+
if err != nil {
+
return &MergeError{
+
Message: err.Error(),
+
OtherError: err,
+
}
+
}
+
defer os.RemoveAll(tmpDir)
+
+
return g.applyPatch(tmpDir, patchFile, true)
+
}
+
+
func (g *GitRepo) Merge(patchData []byte, targetBranch string) error {
+
patchFile, err := g.createTempFileWithPatch(patchData)
+
if err != nil {
+
return &MergeError{
+
Message: err.Error(),
+
OtherError: err,
+
}
+
}
+
defer os.Remove(patchFile)
+
+
tmpDir, err := g.cloneRepository(targetBranch)
+
if err != nil {
+
return &MergeError{
+
Message: err.Error(),
+
OtherError: err,
+
}
+
}
+
defer os.RemoveAll(tmpDir)
+
+
if err := g.applyPatch(tmpDir, patchFile, false); err != nil {
+
return err
+
}
+
+
pushCmd := exec.Command("git", "-C", tmpDir, "push")
+
if err := pushCmd.Run(); err != nil {
+
return &MergeError{
+
Message: "failed to push changes to bare repository",
+
OtherError: err,
+
}
+
}
+
+
return nil
+
}
+
+
func parseGitApplyErrors(errorOutput string) []ConflictInfo {
+
var conflicts []ConflictInfo
+
lines := strings.Split(errorOutput, "\n")
+
+
var currentFile string
+
+
for i := range lines {
+
line := strings.TrimSpace(lines[i])
+
+
if strings.HasPrefix(line, "error: patch failed:") {
+
parts := strings.SplitN(line, ":", 3)
+
if len(parts) >= 3 {
+
currentFile = strings.TrimSpace(parts[2])
+
}
+
continue
+
}
+
+
if match := regexp.MustCompile(`^error: (.*):(\d+): (.*)$`).FindStringSubmatch(line); len(match) >= 4 {
+
if currentFile == "" {
+
currentFile = match[1]
+
}
+
+
conflicts = append(conflicts, ConflictInfo{
+
Filename: currentFile,
+
Reason: match[3],
+
})
+
continue
+
}
+
+
if strings.Contains(line, "already exists in working directory") {
+
conflicts = append(conflicts, ConflictInfo{
+
Filename: currentFile,
+
Reason: "file already exists",
+
})
+
} else if strings.Contains(line, "does not exist in working tree") {
+
conflicts = append(conflicts, ConflictInfo{
+
Filename: currentFile,
+
Reason: "file does not exist",
+
})
+
} else if strings.Contains(line, "patch does not apply") {
+
conflicts = append(conflicts, ConflictInfo{
+
Filename: currentFile,
+
Reason: "patch does not apply",
+
})
+
}
+
}
+
+
return conflicts
+
}
+5
knotserver/handler.go
···
r.Get("/info/refs", h.InfoRefs)
r.Post("/git-upload-pack", h.UploadPack)
+
r.Route("/merge", func(r chi.Router) {
+
r.Post("/", h.Merge)
+
r.Post("/check", h.MergeCheck)
+
})
+
r.Route("/tree/{ref}", func(r chi.Router) {
r.Get("/", h.RepoIndex)
r.Get("/*", h.RepoTree)
+6
knotserver/http_util.go
···
func writeMsg(w http.ResponseWriter, msg string) {
writeJSON(w, map[string]string{"msg": msg})
}
+
+
func writeConflict(w http.ResponseWriter, data interface{}) {
+
w.Header().Set("Content-Type", "application/json")
+
w.WriteHeader(http.StatusConflict)
+
json.NewEncoder(w).Encode(data)
+
}
+98
knotserver/routes.go
···
}
w.WriteHeader(http.StatusNoContent)
+
+
}
+
func (h *Handle) Merge(w http.ResponseWriter, r *http.Request) {
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
+
+
var data struct {
+
Patch string `json:"patch"`
+
Branch string `json:"branch"`
+
}
+
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
+
writeError(w, err.Error(), http.StatusBadRequest)
+
h.l.Error("git: failed to unmarshal json patch", "handler", "Merge", "error", err)
+
return
+
}
+
+
patch := data.Patch
+
branch := data.Branch
+
gr, err := git.Open(path, branch)
+
if err != nil {
+
notFound(w)
+
return
+
}
+
+
if err := gr.Merge([]byte(patch), branch); err != nil {
+
var mergeErr *git.MergeError
+
if errors.As(err, &mergeErr) {
+
conflictDetails := make([]map[string]interface{}, len(mergeErr.Conflicts))
+
for i, conflict := range mergeErr.Conflicts {
+
conflictDetails[i] = map[string]interface{}{
+
"filename": conflict.Filename,
+
"reason": conflict.Reason,
+
}
+
}
+
response := map[string]interface{}{
+
"message": mergeErr.Message,
+
"conflicts": conflictDetails,
+
}
+
writeConflict(w, response)
+
h.l.Error("git: merge conflict", "handler", "Merge", "error", mergeErr)
+
} else {
+
writeError(w, err.Error(), http.StatusBadRequest)
+
h.l.Error("git: failed to merge", "handler", "Merge", "error", err.Error())
+
}
+
return
+
}
+
+
w.WriteHeader(http.StatusOK)
+
}
+
+
func (h *Handle) MergeCheck(w http.ResponseWriter, r *http.Request) {
+
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
+
+
var data struct {
+
Patch string `json:"patch"`
+
Branch string `json:"branch"`
+
}
+
+
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
+
writeError(w, err.Error(), http.StatusBadRequest)
+
h.l.Error("git: failed to unmarshal json patch", "handler", "MergeCheck", "error", err)
+
return
+
}
+
+
patch := data.Patch
+
branch := data.Branch
+
gr, err := git.Open(path, branch)
+
if err != nil {
+
notFound(w)
+
return
+
}
+
+
err = gr.MergeCheck([]byte(patch), branch)
+
if err == nil {
+
w.WriteHeader(http.StatusOK)
+
return
+
}
+
+
var mergeErr *git.MergeError
+
if errors.As(err, &mergeErr) {
+
conflictDetails := make([]map[string]interface{}, len(mergeErr.Conflicts))
+
for i, conflict := range mergeErr.Conflicts {
+
conflictDetails[i] = map[string]interface{}{
+
"filename": conflict.Filename,
+
"reason": conflict.Reason,
+
}
+
}
+
response := map[string]interface{}{
+
"message": mergeErr.Message,
+
"conflicts": conflictDetails,
+
}
+
writeConflict(w, response)
+
h.l.Error("git: merge conflict", "handler", "MergeCheck", "error", mergeErr.Error())
+
return
+
}
+
+
writeError(w, err.Error(), http.StatusInternalServerError)
+
h.l.Error("git: failed to check merge", "handler", "MergeCheck", "error", err.Error())
}
func (h *Handle) AddMember(w http.ResponseWriter, r *http.Request) {
+1
types/merge.go
···
+
package types