Monorepo for Tangled tangled.org
6

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