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