forked from tangled.org/core
this repo has no description
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}