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