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) 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}