forked from tangled.org/core
this repo has no description
1package git 2 3import ( 4 "bytes" 5 "crypto/sha256" 6 "fmt" 7 "os" 8 "os/exec" 9 "regexp" 10 "strings" 11 12 "github.com/dgraph-io/ristretto" 13 "github.com/go-git/go-git/v5" 14 "github.com/go-git/go-git/v5/plumbing" 15 "tangled.sh/tangled.sh/core/patchutil" 16) 17 18type MergeCheckCache struct { 19 cache *ristretto.Cache 20} 21 22var ( 23 mergeCheckCache MergeCheckCache 24) 25 26func init() { 27 cache, _ := ristretto.NewCache(&ristretto.Config{ 28 NumCounters: 1e7, 29 MaxCost: 1 << 30, 30 BufferItems: 64, 31 TtlTickerDurationInSec: 60 * 60 * 24 * 2, // 2 days 32 }) 33 mergeCheckCache = MergeCheckCache{cache} 34} 35 36func (m *MergeCheckCache) cacheKey(g *GitRepo, patch []byte, targetBranch string) string { 37 sep := byte(':') 38 hash := sha256.Sum256(fmt.Append([]byte{}, g.path, sep, g.h.String(), sep, patch, sep, targetBranch)) 39 return fmt.Sprintf("%x", hash) 40} 41 42// we can't cache "mergeable" in risetto, nil is not cacheable 43// 44// we use the sentinel value instead 45func (m *MergeCheckCache) cacheVal(check error) any { 46 if check == nil { 47 return struct{}{} 48 } else { 49 return check 50 } 51} 52 53func (m *MergeCheckCache) Set(g *GitRepo, patch []byte, targetBranch string, mergeCheck error) { 54 key := m.cacheKey(g, patch, targetBranch) 55 val := m.cacheVal(mergeCheck) 56 m.cache.Set(key, val, 0) 57} 58 59func (m *MergeCheckCache) Get(g *GitRepo, patch []byte, targetBranch string) (error, bool) { 60 key := m.cacheKey(g, patch, targetBranch) 61 if val, ok := m.cache.Get(key); ok { 62 if val == struct{}{} { 63 // cache hit for mergeable 64 return nil, true 65 } else if e, ok := val.(error); ok { 66 // cache hit for merge conflict 67 return e, true 68 } 69 } 70 71 // cache miss 72 return nil, false 73} 74 75type ErrMerge struct { 76 Message string 77 Conflicts []ConflictInfo 78 HasConflict bool 79 OtherError error 80} 81 82type ConflictInfo struct { 83 Filename string 84 Reason string 85} 86 87// MergeOptions specifies the configuration for a merge operation 88type MergeOptions struct { 89 CommitMessage string 90 CommitBody string 91 AuthorName string 92 AuthorEmail string 93 FormatPatch bool 94} 95 96func (e ErrMerge) Error() string { 97 if e.HasConflict { 98 return fmt.Sprintf("merge failed due to conflicts: %s (%d conflicts)", e.Message, len(e.Conflicts)) 99 } 100 if e.OtherError != nil { 101 return fmt.Sprintf("merge failed: %s: %v", e.Message, e.OtherError) 102 } 103 return fmt.Sprintf("merge failed: %s", e.Message) 104} 105 106func (g *GitRepo) createTempFileWithPatch(patchData []byte) (string, error) { 107 tmpFile, err := os.CreateTemp("", "git-patch-*.patch") 108 if err != nil { 109 return "", fmt.Errorf("failed to create temporary patch file: %w", err) 110 } 111 112 if _, err := tmpFile.Write(patchData); err != nil { 113 tmpFile.Close() 114 os.Remove(tmpFile.Name()) 115 return "", fmt.Errorf("failed to write patch data to temporary file: %w", err) 116 } 117 118 if err := tmpFile.Close(); err != nil { 119 os.Remove(tmpFile.Name()) 120 return "", fmt.Errorf("failed to close temporary patch file: %w", err) 121 } 122 123 return tmpFile.Name(), nil 124} 125 126func (g *GitRepo) cloneRepository(targetBranch string) (string, error) { 127 tmpDir, err := os.MkdirTemp("", "git-clone-") 128 if err != nil { 129 return "", fmt.Errorf("failed to create temporary directory: %w", err) 130 } 131 132 _, err = git.PlainClone(tmpDir, false, &git.CloneOptions{ 133 URL: "file://" + g.path, 134 Depth: 1, 135 SingleBranch: true, 136 ReferenceName: plumbing.NewBranchReferenceName(targetBranch), 137 }) 138 if err != nil { 139 os.RemoveAll(tmpDir) 140 return "", fmt.Errorf("failed to clone repository: %w", err) 141 } 142 143 return tmpDir, nil 144} 145 146func (g *GitRepo) applyPatch(tmpDir, patchFile string, checkOnly bool, opts *MergeOptions) error { 147 var stderr bytes.Buffer 148 var cmd *exec.Cmd 149 150 if checkOnly { 151 cmd = exec.Command("git", "-C", tmpDir, "apply", "--check", "-v", patchFile) 152 } else { 153 // if patch is a format-patch, apply using 'git am' 154 if opts.FormatPatch { 155 amCmd := exec.Command("git", "-C", tmpDir, "am", patchFile) 156 amCmd.Stderr = &stderr 157 if err := amCmd.Run(); err != nil { 158 return fmt.Errorf("patch application failed: %s", stderr.String()) 159 } 160 return nil 161 } 162 163 // else, apply using 'git apply' and commit it manually 164 exec.Command("git", "-C", tmpDir, "config", "advice.mergeConflict", "false").Run() 165 if opts != nil { 166 applyCmd := exec.Command("git", "-C", tmpDir, "apply", patchFile) 167 applyCmd.Stderr = &stderr 168 if err := applyCmd.Run(); err != nil { 169 return fmt.Errorf("patch application failed: %s", stderr.String()) 170 } 171 172 stageCmd := exec.Command("git", "-C", tmpDir, "add", ".") 173 if err := stageCmd.Run(); err != nil { 174 return fmt.Errorf("failed to stage changes: %w", err) 175 } 176 177 commitArgs := []string{"-C", tmpDir, "commit"} 178 179 // Set author if provided 180 authorName := opts.AuthorName 181 authorEmail := opts.AuthorEmail 182 183 if authorEmail == "" { 184 authorEmail = "noreply@tangled.sh" 185 } 186 187 if authorName == "" { 188 authorName = "Tangled" 189 } 190 191 if authorName != "" { 192 commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail)) 193 } 194 195 commitArgs = append(commitArgs, "-m", opts.CommitMessage) 196 197 if opts.CommitBody != "" { 198 commitArgs = append(commitArgs, "-m", opts.CommitBody) 199 } 200 201 cmd = exec.Command("git", commitArgs...) 202 } else { 203 // If no commit message specified, use git-am which automatically creates a commit 204 cmd = exec.Command("git", "-C", tmpDir, "am", patchFile) 205 } 206 } 207 208 cmd.Stderr = &stderr 209 210 if err := cmd.Run(); err != nil { 211 if checkOnly { 212 conflicts := parseGitApplyErrors(stderr.String()) 213 return &ErrMerge{ 214 Message: "patch cannot be applied cleanly", 215 Conflicts: conflicts, 216 HasConflict: len(conflicts) > 0, 217 OtherError: err, 218 } 219 } 220 return fmt.Errorf("patch application failed: %s", stderr.String()) 221 } 222 223 return nil 224} 225 226func (g *GitRepo) MergeCheck(patchData []byte, targetBranch string) error { 227 if val, ok := mergeCheckCache.Get(g, patchData, targetBranch); ok { 228 return val 229 } 230 231 var opts MergeOptions 232 opts.FormatPatch = patchutil.IsFormatPatch(string(patchData)) 233 234 patchFile, err := g.createTempFileWithPatch(patchData) 235 if err != nil { 236 return &ErrMerge{ 237 Message: err.Error(), 238 OtherError: err, 239 } 240 } 241 defer os.Remove(patchFile) 242 243 tmpDir, err := g.cloneRepository(targetBranch) 244 if err != nil { 245 return &ErrMerge{ 246 Message: err.Error(), 247 OtherError: err, 248 } 249 } 250 defer os.RemoveAll(tmpDir) 251 252 result := g.applyPatch(tmpDir, patchFile, true, &opts) 253 mergeCheckCache.Set(g, patchData, targetBranch, result) 254 return result 255} 256 257func (g *GitRepo) Merge(patchData []byte, targetBranch string) error { 258 return g.MergeWithOptions(patchData, targetBranch, nil) 259} 260 261func (g *GitRepo) MergeWithOptions(patchData []byte, targetBranch string, opts *MergeOptions) error { 262 patchFile, err := g.createTempFileWithPatch(patchData) 263 if err != nil { 264 return &ErrMerge{ 265 Message: err.Error(), 266 OtherError: err, 267 } 268 } 269 defer os.Remove(patchFile) 270 271 tmpDir, err := g.cloneRepository(targetBranch) 272 if err != nil { 273 return &ErrMerge{ 274 Message: err.Error(), 275 OtherError: err, 276 } 277 } 278 defer os.RemoveAll(tmpDir) 279 280 if err := g.applyPatch(tmpDir, patchFile, false, opts); err != nil { 281 return err 282 } 283 284 pushCmd := exec.Command("git", "-C", tmpDir, "push") 285 if err := pushCmd.Run(); err != nil { 286 return &ErrMerge{ 287 Message: "failed to push changes to bare repository", 288 OtherError: err, 289 } 290 } 291 292 return nil 293} 294 295func parseGitApplyErrors(errorOutput string) []ConflictInfo { 296 var conflicts []ConflictInfo 297 lines := strings.Split(errorOutput, "\n") 298 299 var currentFile string 300 301 for i := range lines { 302 line := strings.TrimSpace(lines[i]) 303 304 if strings.HasPrefix(line, "error: patch failed:") { 305 parts := strings.SplitN(line, ":", 3) 306 if len(parts) >= 3 { 307 currentFile = strings.TrimSpace(parts[2]) 308 } 309 continue 310 } 311 312 if match := regexp.MustCompile(`^error: (.*):(\d+): (.*)$`).FindStringSubmatch(line); len(match) >= 4 { 313 if currentFile == "" { 314 currentFile = match[1] 315 } 316 317 conflicts = append(conflicts, ConflictInfo{ 318 Filename: currentFile, 319 Reason: match[3], 320 }) 321 continue 322 } 323 324 if strings.Contains(line, "already exists in working directory") { 325 conflicts = append(conflicts, ConflictInfo{ 326 Filename: currentFile, 327 Reason: "file already exists", 328 }) 329 } else if strings.Contains(line, "does not exist in working tree") { 330 conflicts = append(conflicts, ConflictInfo{ 331 Filename: currentFile, 332 Reason: "file does not exist", 333 }) 334 } else if strings.Contains(line, "patch does not apply") { 335 conflicts = append(conflicts, ConflictInfo{ 336 Filename: currentFile, 337 Reason: "patch does not apply", 338 }) 339 } 340 } 341 342 return conflicts 343}