Mirror of @tangled.org/core. Running on a Raspberry Pi Zero 2 (Please be gentle).
at HEAD 443 lines 12 kB view raw
1package git 2 3import ( 4 "bytes" 5 "crypto/sha256" 6 "fmt" 7 "log" 8 "os" 9 "os/exec" 10 "regexp" 11 "strings" 12 13 "github.com/dgraph-io/ristretto" 14 "github.com/go-git/go-git/v5" 15 "github.com/go-git/go-git/v5/plumbing" 16 "tangled.org/core/patchutil" 17 "tangled.org/core/types" 18) 19 20type MergeCheckCache struct { 21 cache *ristretto.Cache 22} 23 24var ( 25 mergeCheckCache MergeCheckCache 26 conflictErrorRegex = regexp.MustCompile(`^error: (.*):(\d+): (.*)$`) 27) 28 29func init() { 30 cache, _ := ristretto.NewCache(&ristretto.Config{ 31 NumCounters: 1e7, 32 MaxCost: 1 << 30, 33 BufferItems: 64, 34 TtlTickerDurationInSec: 60 * 60 * 24 * 2, // 2 days 35 }) 36 mergeCheckCache = MergeCheckCache{cache} 37} 38 39func (m *MergeCheckCache) cacheKey(g *GitRepo, patch string, targetBranch string) string { 40 sep := byte(':') 41 hash := sha256.Sum256(fmt.Append([]byte{}, g.path, sep, g.h.String(), sep, patch, sep, targetBranch)) 42 return fmt.Sprintf("%x", hash) 43} 44 45// we can't cache "mergeable" in risetto, nil is not cacheable 46// 47// we use the sentinel value instead 48func (m *MergeCheckCache) cacheVal(check error) any { 49 if check == nil { 50 return struct{}{} 51 } else { 52 return check 53 } 54} 55 56func (m *MergeCheckCache) Set(g *GitRepo, patch string, targetBranch string, mergeCheck error) { 57 key := m.cacheKey(g, patch, targetBranch) 58 val := m.cacheVal(mergeCheck) 59 m.cache.Set(key, val, 0) 60} 61 62func (m *MergeCheckCache) Get(g *GitRepo, patch string, targetBranch string) (error, bool) { 63 key := m.cacheKey(g, patch, targetBranch) 64 if val, ok := m.cache.Get(key); ok { 65 if val == struct{}{} { 66 // cache hit for mergeable 67 return nil, true 68 } else if e, ok := val.(error); ok { 69 // cache hit for merge conflict 70 return e, true 71 } 72 } 73 74 // cache miss 75 return nil, false 76} 77 78type ErrMerge struct { 79 Message string 80 Conflicts []ConflictInfo 81 HasConflict bool 82 OtherError error 83} 84 85type ConflictInfo struct { 86 Filename string 87 Reason string 88} 89 90// MergeOptions specifies the configuration for a merge operation 91type MergeOptions struct { 92 CommitMessage string 93 CommitBody string 94 AuthorName string 95 AuthorEmail string 96 CommitterName string 97 CommitterEmail string 98 FormatPatch bool 99} 100 101func (e ErrMerge) Error() string { 102 if e.HasConflict { 103 return fmt.Sprintf("merge failed due to conflicts: %s (%d conflicts)", e.Message, len(e.Conflicts)) 104 } 105 if e.OtherError != nil { 106 return fmt.Sprintf("merge failed: %s: %v", e.Message, e.OtherError) 107 } 108 return fmt.Sprintf("merge failed: %s", e.Message) 109} 110 111func createTemp(data string) (string, error) { 112 tmpFile, err := os.CreateTemp("", "git-patch-*.patch") 113 if err != nil { 114 return "", fmt.Errorf("failed to create temporary patch file: %w", err) 115 } 116 117 if _, err := tmpFile.Write([]byte(data)); err != nil { 118 tmpFile.Close() 119 os.Remove(tmpFile.Name()) 120 return "", fmt.Errorf("failed to write patch data to temporary file: %w", err) 121 } 122 123 if err := tmpFile.Close(); err != nil { 124 os.Remove(tmpFile.Name()) 125 return "", fmt.Errorf("failed to close temporary patch file: %w", err) 126 } 127 128 return tmpFile.Name(), nil 129} 130 131func (g *GitRepo) cloneTemp(targetBranch string) (string, error) { 132 tmpDir, err := os.MkdirTemp("", "git-clone-") 133 if err != nil { 134 return "", fmt.Errorf("failed to create temporary directory: %w", err) 135 } 136 137 _, err = git.PlainClone(tmpDir, false, &git.CloneOptions{ 138 URL: "file://" + g.path, 139 Depth: 1, 140 SingleBranch: true, 141 ReferenceName: plumbing.NewBranchReferenceName(targetBranch), 142 }) 143 if err != nil { 144 os.RemoveAll(tmpDir) 145 return "", fmt.Errorf("failed to clone repository: %w", err) 146 } 147 148 return tmpDir, nil 149} 150 151func (g *GitRepo) applyPatch(patchData, patchFile string, opts MergeOptions) error { 152 var stderr bytes.Buffer 153 var cmd *exec.Cmd 154 155 // configure default git user before merge 156 exec.Command("git", "-C", g.path, "config", "user.name", opts.CommitterName).Run() 157 exec.Command("git", "-C", g.path, "config", "user.email", opts.CommitterEmail).Run() 158 exec.Command("git", "-C", g.path, "config", "advice.mergeConflict", "false").Run() 159 exec.Command("git", "-C", g.path, "config", "advice.amWorkDir", "false").Run() 160 161 // if patch is a format-patch, apply using 'git am' 162 if opts.FormatPatch { 163 return g.applyMailbox(patchData) 164 } 165 166 // else, apply using 'git apply' and commit it manually 167 applyCmd := exec.Command("git", "-C", g.path, "apply", patchFile) 168 applyCmd.Stderr = &stderr 169 if err := applyCmd.Run(); err != nil { 170 return fmt.Errorf("patch application failed: %s", stderr.String()) 171 } 172 173 stageCmd := exec.Command("git", "-C", g.path, "add", ".") 174 if err := stageCmd.Run(); err != nil { 175 return fmt.Errorf("failed to stage changes: %w", err) 176 } 177 178 commitArgs := []string{"-C", g.path, "commit"} 179 180 // Set author if provided 181 authorName := opts.AuthorName 182 authorEmail := opts.AuthorEmail 183 184 if authorName != "" && authorEmail != "" { 185 commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail)) 186 } 187 // else, will default to knot's global user.name & user.email configured via `KNOT_GIT_USER_*` env variables 188 189 commitArgs = append(commitArgs, "-m", opts.CommitMessage) 190 191 if opts.CommitBody != "" { 192 commitArgs = append(commitArgs, "-m", opts.CommitBody) 193 } 194 195 cmd = exec.Command("git", commitArgs...) 196 197 cmd.Stderr = &stderr 198 199 if err := cmd.Run(); err != nil { 200 conflicts := parseGitApplyErrors(stderr.String()) 201 return &ErrMerge{ 202 Message: "patch cannot be applied cleanly", 203 Conflicts: conflicts, 204 HasConflict: len(conflicts) > 0, 205 OtherError: err, 206 } 207 } 208 209 return nil 210} 211 212func (g *GitRepo) applyMailbox(patchData string) error { 213 fps, err := patchutil.ExtractPatches(patchData) 214 if err != nil { 215 return fmt.Errorf("failed to extract patches: %w", err) 216 } 217 218 // apply each patch one by one 219 // update the newly created commit object to add the change-id header 220 total := len(fps) 221 for i, p := range fps { 222 newCommit, err := g.applySingleMailbox(p) 223 if err != nil { 224 return err 225 } 226 227 log.Printf("applying mailbox patch %d/%d: committed %s\n", i+1, total, newCommit.String()) 228 } 229 230 return nil 231} 232 233func (g *GitRepo) applySingleMailbox(singlePatch types.FormatPatch) (plumbing.Hash, error) { 234 tmpPatch, err := createTemp(singlePatch.Raw) 235 if err != nil { 236 return plumbing.ZeroHash, fmt.Errorf("failed to create temporary patch file for singluar mailbox patch: %w", err) 237 } 238 239 var stderr bytes.Buffer 240 cmd := exec.Command("git", "-C", g.path, "am", tmpPatch) 241 cmd.Stderr = &stderr 242 243 head, err := g.r.Head() 244 if err != nil { 245 return plumbing.ZeroHash, err 246 } 247 log.Println("head before apply", head.Hash().String()) 248 249 if err := cmd.Run(); err != nil { 250 conflicts := parseGitApplyErrors(stderr.String()) 251 return plumbing.ZeroHash, &ErrMerge{ 252 Message: "patch cannot be applied cleanly", 253 Conflicts: conflicts, 254 HasConflict: len(conflicts) > 0, 255 OtherError: err, 256 } 257 } 258 259 if err := g.Refresh(); err != nil { 260 return plumbing.ZeroHash, fmt.Errorf("failed to refresh repository state: %w", err) 261 } 262 263 head, err = g.r.Head() 264 if err != nil { 265 return plumbing.ZeroHash, err 266 } 267 log.Println("head after apply", head.Hash().String()) 268 269 newHash := head.Hash() 270 if changeId, err := singlePatch.ChangeId(); err != nil { 271 // no change ID 272 } else if updatedHash, err := g.setChangeId(head.Hash(), changeId); err != nil { 273 return plumbing.ZeroHash, err 274 } else { 275 newHash = updatedHash 276 } 277 278 return newHash, nil 279} 280 281func (g *GitRepo) setChangeId(hash plumbing.Hash, changeId string) (plumbing.Hash, error) { 282 log.Printf("updating change ID of %s to %s\n", hash.String(), changeId) 283 obj, err := g.r.CommitObject(hash) 284 if err != nil { 285 return plumbing.ZeroHash, fmt.Errorf("failed to get commit object for hash %s: %w", hash.String(), err) 286 } 287 288 // write the change-id header 289 obj.ExtraHeaders["change-id"] = []byte(changeId) 290 291 // create a new object 292 dest := g.r.Storer.NewEncodedObject() 293 if err := obj.Encode(dest); err != nil { 294 return plumbing.ZeroHash, fmt.Errorf("failed to create new object: %w", err) 295 } 296 297 // store the new object 298 newHash, err := g.r.Storer.SetEncodedObject(dest) 299 if err != nil { 300 return plumbing.ZeroHash, fmt.Errorf("failed to store new object: %w", err) 301 } 302 303 log.Printf("hash changed from %s to %s\n", obj.Hash.String(), newHash.String()) 304 305 // find the branch that HEAD is pointing to 306 ref, err := g.r.Head() 307 if err != nil { 308 return plumbing.ZeroHash, fmt.Errorf("failed to fetch HEAD: %w", err) 309 } 310 311 // and update that branch to point to new commit 312 if ref.Name().IsBranch() { 313 err = g.r.Storer.SetReference(plumbing.NewHashReference(ref.Name(), newHash)) 314 if err != nil { 315 return plumbing.ZeroHash, fmt.Errorf("failed to update HEAD: %w", err) 316 } 317 } 318 319 // new hash of commit 320 return newHash, nil 321} 322 323func (g *GitRepo) MergeCheckWithOptions(patchData string, targetBranch string, mo MergeOptions) error { 324 if val, ok := mergeCheckCache.Get(g, patchData, targetBranch); ok { 325 return val 326 } 327 328 patchFile, err := createTemp(patchData) 329 if err != nil { 330 return &ErrMerge{ 331 Message: err.Error(), 332 OtherError: err, 333 } 334 } 335 defer os.Remove(patchFile) 336 337 tmpDir, err := g.cloneTemp(targetBranch) 338 if err != nil { 339 return &ErrMerge{ 340 Message: err.Error(), 341 OtherError: err, 342 } 343 } 344 defer os.RemoveAll(tmpDir) 345 346 tmpRepo, err := PlainOpen(tmpDir) 347 if err != nil { 348 return err 349 } 350 351 result := tmpRepo.applyPatch(patchData, patchFile, mo) 352 mergeCheckCache.Set(g, patchData, targetBranch, result) 353 return result 354} 355 356func (g *GitRepo) MergeWithOptions(patchData string, targetBranch string, opts MergeOptions) error { 357 patchFile, err := createTemp(patchData) 358 if err != nil { 359 return &ErrMerge{ 360 Message: err.Error(), 361 OtherError: err, 362 } 363 } 364 defer os.Remove(patchFile) 365 366 tmpDir, err := g.cloneTemp(targetBranch) 367 if err != nil { 368 return &ErrMerge{ 369 Message: err.Error(), 370 OtherError: err, 371 } 372 } 373 defer os.RemoveAll(tmpDir) 374 375 tmpRepo, err := PlainOpen(tmpDir) 376 if err != nil { 377 return err 378 } 379 380 if err := tmpRepo.applyPatch(patchData, patchFile, opts); err != nil { 381 return err 382 } 383 384 pushCmd := exec.Command("git", "-C", tmpDir, "push") 385 if err := pushCmd.Run(); err != nil { 386 return &ErrMerge{ 387 Message: "failed to push changes to bare repository", 388 OtherError: err, 389 } 390 } 391 392 return nil 393} 394 395func parseGitApplyErrors(errorOutput string) []ConflictInfo { 396 var conflicts []ConflictInfo 397 lines := strings.Split(errorOutput, "\n") 398 399 var currentFile string 400 401 for i := range lines { 402 line := strings.TrimSpace(lines[i]) 403 404 if strings.HasPrefix(line, "error: patch failed:") { 405 parts := strings.SplitN(line, ":", 3) 406 if len(parts) >= 3 { 407 currentFile = strings.TrimSpace(parts[2]) 408 } 409 continue 410 } 411 412 if match := conflictErrorRegex.FindStringSubmatch(line); len(match) >= 4 { 413 if currentFile == "" { 414 currentFile = match[1] 415 } 416 417 conflicts = append(conflicts, ConflictInfo{ 418 Filename: currentFile, 419 Reason: match[3], 420 }) 421 continue 422 } 423 424 if strings.Contains(line, "already exists in working directory") { 425 conflicts = append(conflicts, ConflictInfo{ 426 Filename: currentFile, 427 Reason: "file already exists", 428 }) 429 } else if strings.Contains(line, "does not exist in working tree") { 430 conflicts = append(conflicts, ConflictInfo{ 431 Filename: currentFile, 432 Reason: "file does not exist", 433 }) 434 } else if strings.Contains(line, "patch does not apply") { 435 conflicts = append(conflicts, ConflictInfo{ 436 Filename: currentFile, 437 Reason: "patch does not apply", 438 }) 439 } 440 } 441 442 return conflicts 443}