forked from tangled.org/core
this repo has no description
at master 7.0 kB view raw
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}