diff --git a/knotserver/git.go b/knotserver/git.go index a790f6d6..3a7f976a 100644 --- a/knotserver/git.go +++ b/knotserver/git.go @@ -25,7 +25,7 @@ func (d *Handle) InfoRefs(w http.ResponseWriter, r *http.Request) { } 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 } @@ -52,7 +52,7 @@ func (d *Handle) UploadPack(w http.ResponseWriter, r *http.Request) { 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 } @@ -61,7 +61,7 @@ func (d *Handle) UploadPack(w http.ResponseWriter, r *http.Request) { 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 } diff --git a/knotserver/git/merge.go b/knotserver/git/merge.go new file mode 100644 index 00000000..6fa587fe --- /dev/null +++ b/knotserver/git/merge.go @@ -0,0 +1,210 @@ +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 +} diff --git a/knotserver/handler.go b/knotserver/handler.go index be0862df..a828cbb8 100644 --- a/knotserver/handler.go +++ b/knotserver/handler.go @@ -75,6 +75,11 @@ func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, j 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) diff --git a/knotserver/http_util.go b/knotserver/http_util.go index 133c14f1..8b8451ca 100644 --- a/knotserver/http_util.go +++ b/knotserver/http_util.go @@ -24,3 +24,9 @@ func notFound(w http.ResponseWriter) { 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) +} diff --git a/knotserver/routes.go b/knotserver/routes.go index e1126e0c..5b075961 100644 --- a/knotserver/routes.go +++ b/knotserver/routes.go @@ -554,6 +554,104 @@ func (h *Handle) RemoveRepo(w http.ResponseWriter, r *http.Request) { } 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) { diff --git a/types/merge.go b/types/merge.go new file mode 100644 index 00000000..ab1254f4 --- /dev/null +++ b/types/merge.go @@ -0,0 +1 @@ +package types