forked from
tangled.org/core
fork
Configure Feed
Select the types of activity you want to include in your feed.
Monorepo for Tangled
fork
Configure Feed
Select the types of activity you want to include in your feed.
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}