This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / knotserver / git / merge.go
14 kB 517 lines
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 111// createTemp creates a temporary patch file in the system temp directory. 112func createTemp(data string) (string, error) { 113 return createTempIn("", data) 114} 115 116// createTempIn creates a temporary patch file in dir (empty = system /tmp). 117func createTempIn(dir string, data string) (string, error) { 118 tmpFile, err := os.CreateTemp(dir, "git-patch-*.patch") 119 if err != nil { 120 return "", fmt.Errorf("failed to create temporary patch file: %w", err) 121 } 122 123 if _, err := tmpFile.Write([]byte(data)); err != nil { 124 tmpFile.Close() 125 os.Remove(tmpFile.Name()) 126 return "", fmt.Errorf("failed to write patch data to temporary file: %w", err) 127 } 128 129 if err := tmpFile.Close(); err != nil { 130 os.Remove(tmpFile.Name()) 131 return "", fmt.Errorf("failed to close temporary patch file: %w", err) 132 } 133 134 return tmpFile.Name(), nil 135} 136 137func (g *GitRepo) cloneTemp(targetBranch string) (string, error) { 138 tmpDir, err := os.MkdirTemp("", "git-clone-") 139 if err != nil { 140 return "", fmt.Errorf("failed to create temporary directory: %w", err) 141 } 142 143 _, err = git.PlainClone(tmpDir, false, &git.CloneOptions{ 144 URL: "file://" + g.path, 145 Depth: 1, 146 SingleBranch: true, 147 ReferenceName: plumbing.NewBranchReferenceName(targetBranch), 148 }) 149 if err != nil { 150 os.RemoveAll(tmpDir) 151 return "", fmt.Errorf("failed to clone repository: %w", err) 152 } 153 154 return tmpDir, nil 155} 156 157func (g *GitRepo) applyPatch(patchData, patchFile string, opts MergeOptions) error { 158 var stderr bytes.Buffer 159 160 // wrapCmd optionally sandboxes a command to g.path. 161 wrapCmd := func(cmd *exec.Cmd) (*exec.Cmd, error) { 162 if g.sandbox != nil { 163 return g.sandbox.Wrap(g.path, cmd) 164 } 165 cmd.Dir = g.path 166 return cmd, nil 167 } 168 169 // configure default git user before merge 170 for _, cfgArgs := range [][]string{ 171 {"-C", g.path, "config", "user.name", opts.CommitterName}, 172 {"-C", g.path, "config", "user.email", opts.CommitterEmail}, 173 {"-C", g.path, "config", "advice.mergeConflict", "false"}, 174 {"-C", g.path, "config", "advice.amWorkDir", "false"}, 175 } { 176 var cfgStderr bytes.Buffer 177 cfgCmd, _ := wrapCmd(exec.Command("git", cfgArgs...)) 178 cfgCmd.Stderr = &cfgStderr 179 if err := cfgCmd.Run(); err != nil { 180 log.Printf("git config %v failed (non-fatal): err=%v stderr=%q", cfgArgs, err, cfgStderr.String()) 181 } 182 } 183 184 // if patch is a format-patch, apply using 'git am' 185 if opts.FormatPatch { 186 return g.applyMailbox(patchData) 187 } 188 189 // else, apply using 'git apply' and commit it manually 190 applyCmd, err := wrapCmd(exec.Command("git", "-C", g.path, "apply", "--index", patchFile)) 191 if err != nil { 192 return fmt.Errorf("sandbox wrap for git apply: %w", err) 193 } 194 applyCmd.Stderr = &stderr 195 if err := applyCmd.Run(); err != nil { 196 return fmt.Errorf("patch application failed: %s", stderr.String()) 197 } 198 199 commitArgs := []string{"-C", g.path, "commit", "--allow-empty"} 200 201 // Set author if provided 202 authorName := opts.AuthorName 203 authorEmail := opts.AuthorEmail 204 205 if authorName != "" && authorEmail != "" { 206 commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail)) 207 } 208 // else, will default to knot's global user.name & user.email configured via `KNOT_GIT_USER_*` env variables 209 210 commitArgs = append(commitArgs, "-m", opts.CommitMessage) 211 212 if opts.CommitBody != "" { 213 commitArgs = append(commitArgs, "-m", opts.CommitBody) 214 } 215 216 cmd, err := wrapCmd(exec.Command("git", commitArgs...)) 217 if err != nil { 218 return fmt.Errorf("sandbox wrap for git commit: %w", err) 219 } 220 stderr.Reset() 221 cmd.Stderr = &stderr 222 223 if err := cmd.Run(); err != nil { 224 conflicts := parseGitApplyErrors(stderr.String()) 225 log.Printf("git commit failed: err=%v stderr=%q", err, stderr.String()) 226 return &ErrMerge{ 227 Message: "patch cannot be applied cleanly", 228 Conflicts: conflicts, 229 HasConflict: len(conflicts) > 0, 230 OtherError: err, 231 } 232 } 233 234 return nil 235} 236 237func (g *GitRepo) applyMailbox(patchData string) error { 238 fps, err := patchutil.ExtractPatches(patchData) 239 if err != nil { 240 return fmt.Errorf("failed to extract patches: %w", err) 241 } 242 243 // apply each patch one by one 244 // update the newly created commit object to add the change-id header 245 total := len(fps) 246 for i, p := range fps { 247 newCommit, err := g.applySingleMailbox(p) 248 if err != nil { 249 return err 250 } 251 252 log.Printf("applying mailbox patch %d/%d: committed %s\n", i+1, total, newCommit.String()) 253 } 254 255 return nil 256} 257 258func (g *GitRepo) applySingleMailbox(singlePatch types.FormatPatch) (plumbing.Hash, error) { 259 // when sandboxed, create the patch file inside g.path so it is 260 // within the bound directory and visible to the git subprocess. 261 patchDir := "" 262 if g.sandbox != nil { 263 patchDir = g.path 264 } 265 tmpPatch, err := createTempIn(patchDir, singlePatch.Raw) 266 if err != nil { 267 return plumbing.ZeroHash, fmt.Errorf("failed to create temporary patch file for singular mailbox patch: %w", err) 268 } 269 270 var stderr bytes.Buffer 271 rawCmd := exec.Command("git", "-C", g.path, "am", tmpPatch) 272 var cmd *exec.Cmd 273 if g.sandbox != nil { 274 cmd, err = g.sandbox.Wrap(g.path, rawCmd) 275 if err != nil { 276 return plumbing.ZeroHash, fmt.Errorf("sandbox wrap for git am: %w", err) 277 } 278 } else { 279 rawCmd.Dir = g.path 280 cmd = rawCmd 281 } 282 cmd.Stderr = &stderr 283 284 head, err := g.r.Head() 285 if err != nil { 286 return plumbing.ZeroHash, err 287 } 288 log.Println("head before apply", head.Hash().String()) 289 290 if err := cmd.Run(); err != nil { 291 conflicts := parseGitApplyErrors(stderr.String()) 292 log.Printf("git am failed: err=%v stderr=%q", err, stderr.String()) 293 return plumbing.ZeroHash, &ErrMerge{ 294 Message: "patch cannot be applied cleanly", 295 Conflicts: conflicts, 296 HasConflict: len(conflicts) > 0, 297 OtherError: err, 298 } 299 } 300 301 refreshed, err := PlainOpen(g.path) 302 if err != nil { 303 return plumbing.ZeroHash, fmt.Errorf("failed to refresh repository state: %w", err) 304 } 305 *g = *refreshed 306 307 head, err = g.r.Head() 308 if err != nil { 309 return plumbing.ZeroHash, err 310 } 311 log.Println("head after apply", head.Hash().String()) 312 313 newHash := head.Hash() 314 if changeId, err := singlePatch.ChangeId(); err != nil { 315 // no change ID 316 } else if updatedHash, err := g.setChangeId(head.Hash(), changeId); err != nil { 317 return plumbing.ZeroHash, err 318 } else { 319 newHash = updatedHash 320 } 321 322 return newHash, nil 323} 324 325func (g *GitRepo) setChangeId(hash plumbing.Hash, changeId string) (plumbing.Hash, error) { 326 log.Printf("updating change ID of %s to %s\n", hash.String(), changeId) 327 obj, err := g.r.CommitObject(hash) 328 if err != nil { 329 return plumbing.ZeroHash, fmt.Errorf("failed to get commit object for hash %s: %w", hash.String(), err) 330 } 331 332 // write the change-id header 333 obj.ExtraHeaders["change-id"] = []byte(changeId) 334 335 // create a new object 336 dest := g.r.Storer.NewEncodedObject() 337 if err := obj.Encode(dest); err != nil { 338 return plumbing.ZeroHash, fmt.Errorf("failed to create new object: %w", err) 339 } 340 341 // store the new object 342 newHash, err := g.r.Storer.SetEncodedObject(dest) 343 if err != nil { 344 return plumbing.ZeroHash, fmt.Errorf("failed to store new object: %w", err) 345 } 346 347 log.Printf("hash changed from %s to %s\n", obj.Hash.String(), newHash.String()) 348 349 // find the branch that HEAD is pointing to 350 ref, err := g.r.Head() 351 if err != nil { 352 return plumbing.ZeroHash, fmt.Errorf("failed to fetch HEAD: %w", err) 353 } 354 355 // and update that branch to point to new commit 356 if ref.Name().IsBranch() { 357 err = g.r.Storer.SetReference(plumbing.NewHashReference(ref.Name(), newHash)) 358 if err != nil { 359 return plumbing.ZeroHash, fmt.Errorf("failed to update HEAD: %w", err) 360 } 361 } 362 363 // new hash of commit 364 return newHash, nil 365} 366 367func (g *GitRepo) MergeCheckWithOptions(patchData string, targetBranch string, mo MergeOptions) error { 368 if val, ok := mergeCheckCache.Get(g, patchData, targetBranch); ok { 369 return val 370 } 371 372 tmpDir, err := g.cloneTemp(targetBranch) 373 if err != nil { 374 return &ErrMerge{ 375 Message: err.Error(), 376 OtherError: err, 377 } 378 } 379 defer os.RemoveAll(tmpDir) 380 381 // when sandboxed, create the patch file inside tmpDir so it is 382 // visible to the git subprocess. 383 patchDir := "" 384 if g.sandbox != nil { 385 patchDir = tmpDir 386 } 387 patchFile, err := createTempIn(patchDir, patchData) 388 if err != nil { 389 return &ErrMerge{ 390 Message: err.Error(), 391 OtherError: err, 392 } 393 } 394 defer os.Remove(patchFile) 395 396 tmpRepo, err := PlainOpen(tmpDir) 397 if err != nil { 398 return err 399 } 400 if g.sandbox != nil { 401 tmpRepo = tmpRepo.WithSandbox(g.sandbox) 402 } 403 404 result := tmpRepo.applyPatch(patchData, patchFile, mo) 405 mergeCheckCache.Set(g, patchData, targetBranch, result) 406 return result 407} 408 409func (g *GitRepo) MergeWithOptions(patchData string, targetBranch string, opts MergeOptions) error { 410 tmpDir, err := g.cloneTemp(targetBranch) 411 if err != nil { 412 return &ErrMerge{ 413 Message: err.Error(), 414 OtherError: err, 415 } 416 } 417 defer os.RemoveAll(tmpDir) 418 419 // when sandboxed, create the patch file inside tmpDir so it is 420 // visible to the git subprocess. 421 patchDir := "" 422 if g.sandbox != nil { 423 patchDir = tmpDir 424 } 425 patchFile, err := createTempIn(patchDir, patchData) 426 if err != nil { 427 return &ErrMerge{ 428 Message: err.Error(), 429 OtherError: err, 430 } 431 } 432 defer os.Remove(patchFile) 433 434 tmpRepo, err := PlainOpen(tmpDir) 435 if err != nil { 436 return err 437 } 438 if g.sandbox != nil { 439 tmpRepo = tmpRepo.WithSandbox(g.sandbox) 440 } 441 442 if err := tmpRepo.applyPatch(patchData, patchFile, opts); err != nil { 443 return err 444 } 445 446 pushCmd := exec.Command("git", "-C", tmpDir, "push") 447 if g.sandbox != nil { 448 // the push needs access to both tmpDir (source) and g.path (target bare repo). 449 pushCmd, err = g.sandbox.WrapMulti([]string{tmpDir, g.path}, pushCmd) 450 if err != nil { 451 return &ErrMerge{ 452 Message: "sandbox wrap for git push failed", 453 OtherError: err, 454 } 455 } 456 } else { 457 pushCmd.Dir = tmpDir 458 } 459 if err := pushCmd.Run(); err != nil { 460 return &ErrMerge{ 461 Message: "failed to push changes to bare repository", 462 OtherError: err, 463 } 464 } 465 466 return nil 467} 468 469func parseGitApplyErrors(errorOutput string) []ConflictInfo { 470 var conflicts []ConflictInfo 471 lines := strings.Split(errorOutput, "\n") 472 473 var currentFile string 474 475 for i := range lines { 476 line := strings.TrimSpace(lines[i]) 477 478 if strings.HasPrefix(line, "error: patch failed:") { 479 parts := strings.SplitN(line, ":", 3) 480 if len(parts) >= 3 { 481 currentFile = strings.TrimSpace(parts[2]) 482 } 483 continue 484 } 485 486 if match := conflictErrorRegex.FindStringSubmatch(line); len(match) >= 4 { 487 if currentFile == "" { 488 currentFile = match[1] 489 } 490 491 conflicts = append(conflicts, ConflictInfo{ 492 Filename: currentFile, 493 Reason: match[3], 494 }) 495 continue 496 } 497 498 if strings.Contains(line, "already exists in working directory") { 499 conflicts = append(conflicts, ConflictInfo{ 500 Filename: currentFile, 501 Reason: "file already exists", 502 }) 503 } else if strings.Contains(line, "does not exist in working tree") { 504 conflicts = append(conflicts, ConflictInfo{ 505 Filename: currentFile, 506 Reason: "file does not exist", 507 }) 508 } else if strings.Contains(line, "patch does not apply") { 509 conflicts = append(conflicts, ConflictInfo{ 510 Filename: currentFile, 511 Reason: "patch does not apply", 512 }) 513 } 514 } 515 516 return conflicts 517}