Monorepo for Tangled tangled.org
5

Configure Feed

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

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", 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 stderr.Reset() 200 stageCmd, err := wrapCmd(exec.Command("git", "-C", g.path, "add", ".")) 201 if err != nil { 202 return fmt.Errorf("sandbox wrap for git add: %w", err) 203 } 204 if err := stageCmd.Run(); err != nil { 205 return fmt.Errorf("failed to stage changes: %w", err) 206 } 207 208 commitArgs := []string{"-C", g.path, "commit", "--allow-empty"} 209 210 // Set author if provided 211 authorName := opts.AuthorName 212 authorEmail := opts.AuthorEmail 213 214 if authorName != "" && authorEmail != "" { 215 commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail)) 216 } 217 // else, will default to knot's global user.name & user.email configured via `KNOT_GIT_USER_*` env variables 218 219 commitArgs = append(commitArgs, "-m", opts.CommitMessage) 220 221 if opts.CommitBody != "" { 222 commitArgs = append(commitArgs, "-m", opts.CommitBody) 223 } 224 225 cmd, err := wrapCmd(exec.Command("git", commitArgs...)) 226 if err != nil { 227 return fmt.Errorf("sandbox wrap for git commit: %w", err) 228 } 229 stderr.Reset() 230 cmd.Stderr = &stderr 231 232 if err := cmd.Run(); err != nil { 233 conflicts := parseGitApplyErrors(stderr.String()) 234 log.Printf("git commit failed: err=%v stderr=%q", err, stderr.String()) 235 return &ErrMerge{ 236 Message: "patch cannot be applied cleanly", 237 Conflicts: conflicts, 238 HasConflict: len(conflicts) > 0, 239 OtherError: err, 240 } 241 } 242 243 return nil 244} 245 246func (g *GitRepo) applyMailbox(patchData string) error { 247 fps, err := patchutil.ExtractPatches(patchData) 248 if err != nil { 249 return fmt.Errorf("failed to extract patches: %w", err) 250 } 251 252 // apply each patch one by one 253 // update the newly created commit object to add the change-id header 254 total := len(fps) 255 for i, p := range fps { 256 newCommit, err := g.applySingleMailbox(p) 257 if err != nil { 258 return err 259 } 260 261 log.Printf("applying mailbox patch %d/%d: committed %s\n", i+1, total, newCommit.String()) 262 } 263 264 return nil 265} 266 267func (g *GitRepo) applySingleMailbox(singlePatch types.FormatPatch) (plumbing.Hash, error) { 268 // when sandboxed, create the patch file inside g.path so it is 269 // within the bound directory and visible to the git subprocess. 270 patchDir := "" 271 if g.sandbox != nil { 272 patchDir = g.path 273 } 274 tmpPatch, err := createTempIn(patchDir, singlePatch.Raw) 275 if err != nil { 276 return plumbing.ZeroHash, fmt.Errorf("failed to create temporary patch file for singular mailbox patch: %w", err) 277 } 278 279 var stderr bytes.Buffer 280 rawCmd := exec.Command("git", "-C", g.path, "am", tmpPatch) 281 var cmd *exec.Cmd 282 if g.sandbox != nil { 283 cmd, err = g.sandbox.Wrap(g.path, rawCmd) 284 if err != nil { 285 return plumbing.ZeroHash, fmt.Errorf("sandbox wrap for git am: %w", err) 286 } 287 } else { 288 rawCmd.Dir = g.path 289 cmd = rawCmd 290 } 291 cmd.Stderr = &stderr 292 293 head, err := g.r.Head() 294 if err != nil { 295 return plumbing.ZeroHash, err 296 } 297 log.Println("head before apply", head.Hash().String()) 298 299 if err := cmd.Run(); err != nil { 300 conflicts := parseGitApplyErrors(stderr.String()) 301 log.Printf("git am failed: err=%v stderr=%q", err, stderr.String()) 302 return plumbing.ZeroHash, &ErrMerge{ 303 Message: "patch cannot be applied cleanly", 304 Conflicts: conflicts, 305 HasConflict: len(conflicts) > 0, 306 OtherError: err, 307 } 308 } 309 310 refreshed, err := PlainOpen(g.path) 311 if err != nil { 312 return plumbing.ZeroHash, fmt.Errorf("failed to refresh repository state: %w", err) 313 } 314 *g = *refreshed 315 316 head, err = g.r.Head() 317 if err != nil { 318 return plumbing.ZeroHash, err 319 } 320 log.Println("head after apply", head.Hash().String()) 321 322 newHash := head.Hash() 323 if changeId, err := singlePatch.ChangeId(); err != nil { 324 // no change ID 325 } else if updatedHash, err := g.setChangeId(head.Hash(), changeId); err != nil { 326 return plumbing.ZeroHash, err 327 } else { 328 newHash = updatedHash 329 } 330 331 return newHash, nil 332} 333 334func (g *GitRepo) setChangeId(hash plumbing.Hash, changeId string) (plumbing.Hash, error) { 335 log.Printf("updating change ID of %s to %s\n", hash.String(), changeId) 336 obj, err := g.r.CommitObject(hash) 337 if err != nil { 338 return plumbing.ZeroHash, fmt.Errorf("failed to get commit object for hash %s: %w", hash.String(), err) 339 } 340 341 // write the change-id header 342 obj.ExtraHeaders["change-id"] = []byte(changeId) 343 344 // create a new object 345 dest := g.r.Storer.NewEncodedObject() 346 if err := obj.Encode(dest); err != nil { 347 return plumbing.ZeroHash, fmt.Errorf("failed to create new object: %w", err) 348 } 349 350 // store the new object 351 newHash, err := g.r.Storer.SetEncodedObject(dest) 352 if err != nil { 353 return plumbing.ZeroHash, fmt.Errorf("failed to store new object: %w", err) 354 } 355 356 log.Printf("hash changed from %s to %s\n", obj.Hash.String(), newHash.String()) 357 358 // find the branch that HEAD is pointing to 359 ref, err := g.r.Head() 360 if err != nil { 361 return plumbing.ZeroHash, fmt.Errorf("failed to fetch HEAD: %w", err) 362 } 363 364 // and update that branch to point to new commit 365 if ref.Name().IsBranch() { 366 err = g.r.Storer.SetReference(plumbing.NewHashReference(ref.Name(), newHash)) 367 if err != nil { 368 return plumbing.ZeroHash, fmt.Errorf("failed to update HEAD: %w", err) 369 } 370 } 371 372 // new hash of commit 373 return newHash, nil 374} 375 376func (g *GitRepo) MergeCheckWithOptions(patchData string, targetBranch string, mo MergeOptions) error { 377 if val, ok := mergeCheckCache.Get(g, patchData, targetBranch); ok { 378 return val 379 } 380 381 tmpDir, err := g.cloneTemp(targetBranch) 382 if err != nil { 383 return &ErrMerge{ 384 Message: err.Error(), 385 OtherError: err, 386 } 387 } 388 defer os.RemoveAll(tmpDir) 389 390 // when sandboxed, create the patch file inside tmpDir so it is 391 // visible to the git subprocess. 392 patchDir := "" 393 if g.sandbox != nil { 394 patchDir = tmpDir 395 } 396 patchFile, err := createTempIn(patchDir, patchData) 397 if err != nil { 398 return &ErrMerge{ 399 Message: err.Error(), 400 OtherError: err, 401 } 402 } 403 defer os.Remove(patchFile) 404 405 tmpRepo, err := PlainOpen(tmpDir) 406 if err != nil { 407 return err 408 } 409 if g.sandbox != nil { 410 tmpRepo = tmpRepo.WithSandbox(g.sandbox) 411 } 412 413 result := tmpRepo.applyPatch(patchData, patchFile, mo) 414 mergeCheckCache.Set(g, patchData, targetBranch, result) 415 return result 416} 417 418func (g *GitRepo) MergeWithOptions(patchData string, targetBranch string, opts MergeOptions) error { 419 tmpDir, err := g.cloneTemp(targetBranch) 420 if err != nil { 421 return &ErrMerge{ 422 Message: err.Error(), 423 OtherError: err, 424 } 425 } 426 defer os.RemoveAll(tmpDir) 427 428 // when sandboxed, create the patch file inside tmpDir so it is 429 // visible to the git subprocess. 430 patchDir := "" 431 if g.sandbox != nil { 432 patchDir = tmpDir 433 } 434 patchFile, err := createTempIn(patchDir, patchData) 435 if err != nil { 436 return &ErrMerge{ 437 Message: err.Error(), 438 OtherError: err, 439 } 440 } 441 defer os.Remove(patchFile) 442 443 tmpRepo, err := PlainOpen(tmpDir) 444 if err != nil { 445 return err 446 } 447 if g.sandbox != nil { 448 tmpRepo = tmpRepo.WithSandbox(g.sandbox) 449 } 450 451 if err := tmpRepo.applyPatch(patchData, patchFile, opts); err != nil { 452 return err 453 } 454 455 pushCmd := exec.Command("git", "-C", tmpDir, "push") 456 if g.sandbox != nil { 457 // the push needs access to both tmpDir (source) and g.path (target bare repo). 458 pushCmd, err = g.sandbox.WrapMulti([]string{tmpDir, g.path}, pushCmd) 459 if err != nil { 460 return &ErrMerge{ 461 Message: "sandbox wrap for git push failed", 462 OtherError: err, 463 } 464 } 465 } else { 466 pushCmd.Dir = tmpDir 467 } 468 if err := pushCmd.Run(); err != nil { 469 return &ErrMerge{ 470 Message: "failed to push changes to bare repository", 471 OtherError: err, 472 } 473 } 474 475 return nil 476} 477 478func parseGitApplyErrors(errorOutput string) []ConflictInfo { 479 var conflicts []ConflictInfo 480 lines := strings.Split(errorOutput, "\n") 481 482 var currentFile string 483 484 for i := range lines { 485 line := strings.TrimSpace(lines[i]) 486 487 if strings.HasPrefix(line, "error: patch failed:") { 488 parts := strings.SplitN(line, ":", 3) 489 if len(parts) >= 3 { 490 currentFile = strings.TrimSpace(parts[2]) 491 } 492 continue 493 } 494 495 if match := conflictErrorRegex.FindStringSubmatch(line); len(match) >= 4 { 496 if currentFile == "" { 497 currentFile = match[1] 498 } 499 500 conflicts = append(conflicts, ConflictInfo{ 501 Filename: currentFile, 502 Reason: match[3], 503 }) 504 continue 505 } 506 507 if strings.Contains(line, "already exists in working directory") { 508 conflicts = append(conflicts, ConflictInfo{ 509 Filename: currentFile, 510 Reason: "file already exists", 511 }) 512 } else if strings.Contains(line, "does not exist in working tree") { 513 conflicts = append(conflicts, ConflictInfo{ 514 Filename: currentFile, 515 Reason: "file does not exist", 516 }) 517 } else if strings.Contains(line, "patch does not apply") { 518 conflicts = append(conflicts, ConflictInfo{ 519 Filename: currentFile, 520 Reason: "patch does not apply", 521 }) 522 } 523 } 524 525 return conflicts 526}