forked from tangled.org/core
Monorepo for Tangled — https://tangled.org
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 MergeError 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 27func (e MergeError) Error() string { 28 if e.HasConflict { 29 return fmt.Sprintf("merge failed due to conflicts: %s (%d conflicts)", e.Message, len(e.Conflicts)) 30 } 31 if e.OtherError != nil { 32 return fmt.Sprintf("merge failed: %s: %v", e.Message, e.OtherError) 33 } 34 return fmt.Sprintf("merge failed: %s", e.Message) 35} 36 37func (g *GitRepo) createTempFileWithPatch(patchData []byte) (string, error) { 38 tmpFile, err := os.CreateTemp("", "git-patch-*.patch") 39 if err != nil { 40 return "", fmt.Errorf("failed to create temporary patch file: %w", err) 41 } 42 43 if _, err := tmpFile.Write(patchData); err != nil { 44 tmpFile.Close() 45 os.Remove(tmpFile.Name()) 46 return "", fmt.Errorf("failed to write patch data to temporary file: %w", err) 47 } 48 49 if err := tmpFile.Close(); err != nil { 50 os.Remove(tmpFile.Name()) 51 return "", fmt.Errorf("failed to close temporary patch file: %w", err) 52 } 53 54 return tmpFile.Name(), nil 55} 56 57func (g *GitRepo) cloneRepository(targetBranch string) (string, error) { 58 tmpDir, err := os.MkdirTemp("", "git-clone-") 59 if err != nil { 60 return "", fmt.Errorf("failed to create temporary directory: %w", err) 61 } 62 63 _, err = git.PlainClone(tmpDir, false, &git.CloneOptions{ 64 URL: "file://" + g.path, 65 Depth: 1, 66 SingleBranch: true, 67 ReferenceName: plumbing.NewBranchReferenceName(targetBranch), 68 }) 69 if err != nil { 70 os.RemoveAll(tmpDir) 71 return "", fmt.Errorf("failed to clone repository: %w", err) 72 } 73 74 return tmpDir, nil 75} 76 77func (g *GitRepo) applyPatch(tmpDir, patchFile string, checkOnly bool) error { 78 var stderr bytes.Buffer 79 var cmd *exec.Cmd 80 81 if checkOnly { 82 cmd = exec.Command("git", "-C", tmpDir, "apply", "--check", "-v", patchFile) 83 } else { 84 exec.Command("git", "-C", tmpDir, "config", "advice.mergeConflict", "false").Run() 85 cmd = exec.Command("git", "-C", tmpDir, "am", patchFile) 86 } 87 88 cmd.Stderr = &stderr 89 90 if err := cmd.Run(); err != nil { 91 if checkOnly { 92 conflicts := parseGitApplyErrors(stderr.String()) 93 return &MergeError{ 94 Message: "patch cannot be applied cleanly", 95 Conflicts: conflicts, 96 HasConflict: len(conflicts) > 0, 97 OtherError: err, 98 } 99 } 100 return fmt.Errorf("patch application failed: %s", stderr.String()) 101 } 102 103 return nil 104} 105 106func (g *GitRepo) MergeCheck(patchData []byte, targetBranch string) error { 107 patchFile, err := g.createTempFileWithPatch(patchData) 108 if err != nil { 109 return &MergeError{ 110 Message: err.Error(), 111 OtherError: err, 112 } 113 } 114 defer os.Remove(patchFile) 115 116 tmpDir, err := g.cloneRepository(targetBranch) 117 if err != nil { 118 return &MergeError{ 119 Message: err.Error(), 120 OtherError: err, 121 } 122 } 123 defer os.RemoveAll(tmpDir) 124 125 return g.applyPatch(tmpDir, patchFile, true) 126} 127 128func (g *GitRepo) Merge(patchData []byte, targetBranch string) error { 129 patchFile, err := g.createTempFileWithPatch(patchData) 130 if err != nil { 131 return &MergeError{ 132 Message: err.Error(), 133 OtherError: err, 134 } 135 } 136 defer os.Remove(patchFile) 137 138 tmpDir, err := g.cloneRepository(targetBranch) 139 if err != nil { 140 return &MergeError{ 141 Message: err.Error(), 142 OtherError: err, 143 } 144 } 145 defer os.RemoveAll(tmpDir) 146 147 if err := g.applyPatch(tmpDir, patchFile, false); err != nil { 148 return err 149 } 150 151 pushCmd := exec.Command("git", "-C", tmpDir, "push") 152 if err := pushCmd.Run(); err != nil { 153 return &MergeError{ 154 Message: "failed to push changes to bare repository", 155 OtherError: err, 156 } 157 } 158 159 return nil 160} 161 162func parseGitApplyErrors(errorOutput string) []ConflictInfo { 163 var conflicts []ConflictInfo 164 lines := strings.Split(errorOutput, "\n") 165 166 var currentFile string 167 168 for i := range lines { 169 line := strings.TrimSpace(lines[i]) 170 171 if strings.HasPrefix(line, "error: patch failed:") { 172 parts := strings.SplitN(line, ":", 3) 173 if len(parts) >= 3 { 174 currentFile = strings.TrimSpace(parts[2]) 175 } 176 continue 177 } 178 179 if match := regexp.MustCompile(`^error: (.*):(\d+): (.*)$`).FindStringSubmatch(line); len(match) >= 4 { 180 if currentFile == "" { 181 currentFile = match[1] 182 } 183 184 conflicts = append(conflicts, ConflictInfo{ 185 Filename: currentFile, 186 Reason: match[3], 187 }) 188 continue 189 } 190 191 if strings.Contains(line, "already exists in working directory") { 192 conflicts = append(conflicts, ConflictInfo{ 193 Filename: currentFile, 194 Reason: "file already exists", 195 }) 196 } else if strings.Contains(line, "does not exist in working tree") { 197 conflicts = append(conflicts, ConflictInfo{ 198 Filename: currentFile, 199 Reason: "file does not exist", 200 }) 201 } else if strings.Contains(line, "patch does not apply") { 202 conflicts = append(conflicts, ConflictInfo{ 203 Filename: currentFile, 204 Reason: "patch does not apply", 205 }) 206 } 207 } 208 209 return conflicts 210}