1package git
2
3import (
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 "tangled.sh/tangled.sh/core/patchutil"
14)
15
16type ErrMerge struct {
17 Message string
18 Conflicts []ConflictInfo
19 HasConflict bool
20 OtherError error
21}
22
23type ConflictInfo struct {
24 Filename string
25 Reason string
26}
27
28// MergeOptions specifies the configuration for a merge operation
29type MergeOptions struct {
30 CommitMessage string
31 CommitBody string
32 AuthorName string
33 AuthorEmail string
34 FormatPatch bool
35}
36
37func (e ErrMerge) Error() string {
38 if e.HasConflict {
39 return fmt.Sprintf("merge failed due to conflicts: %s (%d conflicts)", e.Message, len(e.Conflicts))
40 }
41 if e.OtherError != nil {
42 return fmt.Sprintf("merge failed: %s: %v", e.Message, e.OtherError)
43 }
44 return fmt.Sprintf("merge failed: %s", e.Message)
45}
46
47func (g *GitRepo) createTempFileWithPatch(patchData []byte) (string, error) {
48 tmpFile, err := os.CreateTemp("", "git-patch-*.patch")
49 if err != nil {
50 return "", fmt.Errorf("failed to create temporary patch file: %w", err)
51 }
52
53 if _, err := tmpFile.Write(patchData); err != nil {
54 tmpFile.Close()
55 os.Remove(tmpFile.Name())
56 return "", fmt.Errorf("failed to write patch data to temporary file: %w", err)
57 }
58
59 if err := tmpFile.Close(); err != nil {
60 os.Remove(tmpFile.Name())
61 return "", fmt.Errorf("failed to close temporary patch file: %w", err)
62 }
63
64 return tmpFile.Name(), nil
65}
66
67func (g *GitRepo) cloneRepository(targetBranch string) (string, error) {
68 tmpDir, err := os.MkdirTemp("", "git-clone-")
69 if err != nil {
70 return "", fmt.Errorf("failed to create temporary directory: %w", err)
71 }
72
73 _, err = git.PlainClone(tmpDir, false, &git.CloneOptions{
74 URL: "file://" + g.path,
75 Depth: 1,
76 SingleBranch: true,
77 ReferenceName: plumbing.NewBranchReferenceName(targetBranch),
78 })
79 if err != nil {
80 os.RemoveAll(tmpDir)
81 return "", fmt.Errorf("failed to clone repository: %w", err)
82 }
83
84 return tmpDir, nil
85}
86
87func (g *GitRepo) applyPatch(tmpDir, patchFile string, checkOnly bool, opts *MergeOptions) error {
88 var stderr bytes.Buffer
89 var cmd *exec.Cmd
90
91 if checkOnly {
92 cmd = exec.Command("git", "-C", tmpDir, "apply", "--check", "-v", patchFile)
93 } else {
94 // if patch is a format-patch, apply using 'git am'
95 if opts.FormatPatch {
96 amCmd := exec.Command("git", "-C", tmpDir, "am", patchFile)
97 amCmd.Stderr = &stderr
98 if err := amCmd.Run(); err != nil {
99 return fmt.Errorf("patch application failed: %s", stderr.String())
100 }
101 return nil
102 }
103
104 // else, apply using 'git apply' and commit it manually
105 exec.Command("git", "-C", tmpDir, "config", "advice.mergeConflict", "false").Run()
106 if opts != nil {
107 applyCmd := exec.Command("git", "-C", tmpDir, "apply", patchFile)
108 applyCmd.Stderr = &stderr
109 if err := applyCmd.Run(); err != nil {
110 return fmt.Errorf("patch application failed: %s", stderr.String())
111 }
112
113 stageCmd := exec.Command("git", "-C", tmpDir, "add", ".")
114 if err := stageCmd.Run(); err != nil {
115 return fmt.Errorf("failed to stage changes: %w", err)
116 }
117
118 commitArgs := []string{"-C", tmpDir, "commit"}
119
120 // Set author if provided
121 authorName := opts.AuthorName
122 authorEmail := opts.AuthorEmail
123
124 if authorEmail == "" {
125 authorEmail = "noreply@tangled.sh"
126 }
127
128 if authorName == "" {
129 authorName = "Tangled"
130 }
131
132 if authorName != "" {
133 commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail))
134 }
135
136 commitArgs = append(commitArgs, "-m", opts.CommitMessage)
137
138 if opts.CommitBody != "" {
139 commitArgs = append(commitArgs, "-m", opts.CommitBody)
140 }
141
142 cmd = exec.Command("git", commitArgs...)
143 } else {
144 // If no commit message specified, use git-am which automatically creates a commit
145 cmd = exec.Command("git", "-C", tmpDir, "am", patchFile)
146 }
147 }
148
149 cmd.Stderr = &stderr
150
151 if err := cmd.Run(); err != nil {
152 if checkOnly {
153 conflicts := parseGitApplyErrors(stderr.String())
154 return &ErrMerge{
155 Message: "patch cannot be applied cleanly",
156 Conflicts: conflicts,
157 HasConflict: len(conflicts) > 0,
158 OtherError: err,
159 }
160 }
161 return fmt.Errorf("patch application failed: %s", stderr.String())
162 }
163
164 return nil
165}
166
167func (g *GitRepo) MergeCheck(patchData []byte, targetBranch string) error {
168 var opts MergeOptions
169 opts.FormatPatch = patchutil.IsFormatPatch(string(patchData))
170
171 patchFile, err := g.createTempFileWithPatch(patchData)
172 if err != nil {
173 return &ErrMerge{
174 Message: err.Error(),
175 OtherError: err,
176 }
177 }
178 defer os.Remove(patchFile)
179
180 tmpDir, err := g.cloneRepository(targetBranch)
181 if err != nil {
182 return &ErrMerge{
183 Message: err.Error(),
184 OtherError: err,
185 }
186 }
187 defer os.RemoveAll(tmpDir)
188
189 return g.applyPatch(tmpDir, patchFile, true, &opts)
190}
191
192func (g *GitRepo) Merge(patchData []byte, targetBranch string) error {
193 return g.MergeWithOptions(patchData, targetBranch, nil)
194}
195
196func (g *GitRepo) MergeWithOptions(patchData []byte, targetBranch string, opts *MergeOptions) error {
197 patchFile, err := g.createTempFileWithPatch(patchData)
198 if err != nil {
199 return &ErrMerge{
200 Message: err.Error(),
201 OtherError: err,
202 }
203 }
204 defer os.Remove(patchFile)
205
206 tmpDir, err := g.cloneRepository(targetBranch)
207 if err != nil {
208 return &ErrMerge{
209 Message: err.Error(),
210 OtherError: err,
211 }
212 }
213 defer os.RemoveAll(tmpDir)
214
215 if err := g.applyPatch(tmpDir, patchFile, false, opts); err != nil {
216 return err
217 }
218
219 pushCmd := exec.Command("git", "-C", tmpDir, "push")
220 if err := pushCmd.Run(); err != nil {
221 return &ErrMerge{
222 Message: "failed to push changes to bare repository",
223 OtherError: err,
224 }
225 }
226
227 return nil
228}
229
230func parseGitApplyErrors(errorOutput string) []ConflictInfo {
231 var conflicts []ConflictInfo
232 lines := strings.Split(errorOutput, "\n")
233
234 var currentFile string
235
236 for i := range lines {
237 line := strings.TrimSpace(lines[i])
238
239 if strings.HasPrefix(line, "error: patch failed:") {
240 parts := strings.SplitN(line, ":", 3)
241 if len(parts) >= 3 {
242 currentFile = strings.TrimSpace(parts[2])
243 }
244 continue
245 }
246
247 if match := regexp.MustCompile(`^error: (.*):(\d+): (.*)$`).FindStringSubmatch(line); len(match) >= 4 {
248 if currentFile == "" {
249 currentFile = match[1]
250 }
251
252 conflicts = append(conflicts, ConflictInfo{
253 Filename: currentFile,
254 Reason: match[3],
255 })
256 continue
257 }
258
259 if strings.Contains(line, "already exists in working directory") {
260 conflicts = append(conflicts, ConflictInfo{
261 Filename: currentFile,
262 Reason: "file already exists",
263 })
264 } else if strings.Contains(line, "does not exist in working tree") {
265 conflicts = append(conflicts, ConflictInfo{
266 Filename: currentFile,
267 Reason: "file does not exist",
268 })
269 } else if strings.Contains(line, "patch does not apply") {
270 conflicts = append(conflicts, ConflictInfo{
271 Filename: currentFile,
272 Reason: "patch does not apply",
273 })
274 }
275 }
276
277 return conflicts
278}