+3
-3
knotserver/git.go
+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
+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
+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
+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
+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
types/merge.go
···
1
+
package types