Monorepo for Tangled tangled.org
782
fork

Configure Feed

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

at lt/repo-rename-by-rkey 445 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", "--allow-empty"} 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 singular 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 refreshed, err := PlainOpen(g.path) 260 if err != nil { 261 return plumbing.ZeroHash, fmt.Errorf("failed to refresh repository state: %w", err) 262 } 263 *g = *refreshed 264 265 head, err = g.r.Head() 266 if err != nil { 267 return plumbing.ZeroHash, err 268 } 269 log.Println("head after apply", head.Hash().String()) 270 271 newHash := head.Hash() 272 if changeId, err := singlePatch.ChangeId(); err != nil { 273 // no change ID 274 } else if updatedHash, err := g.setChangeId(head.Hash(), changeId); err != nil { 275 return plumbing.ZeroHash, err 276 } else { 277 newHash = updatedHash 278 } 279 280 return newHash, nil 281} 282 283func (g *GitRepo) setChangeId(hash plumbing.Hash, changeId string) (plumbing.Hash, error) { 284 log.Printf("updating change ID of %s to %s\n", hash.String(), changeId) 285 obj, err := g.r.CommitObject(hash) 286 if err != nil { 287 return plumbing.ZeroHash, fmt.Errorf("failed to get commit object for hash %s: %w", hash.String(), err) 288 } 289 290 // write the change-id header 291 obj.ExtraHeaders["change-id"] = []byte(changeId) 292 293 // create a new object 294 dest := g.r.Storer.NewEncodedObject() 295 if err := obj.Encode(dest); err != nil { 296 return plumbing.ZeroHash, fmt.Errorf("failed to create new object: %w", err) 297 } 298 299 // store the new object 300 newHash, err := g.r.Storer.SetEncodedObject(dest) 301 if err != nil { 302 return plumbing.ZeroHash, fmt.Errorf("failed to store new object: %w", err) 303 } 304 305 log.Printf("hash changed from %s to %s\n", obj.Hash.String(), newHash.String()) 306 307 // find the branch that HEAD is pointing to 308 ref, err := g.r.Head() 309 if err != nil { 310 return plumbing.ZeroHash, fmt.Errorf("failed to fetch HEAD: %w", err) 311 } 312 313 // and update that branch to point to new commit 314 if ref.Name().IsBranch() { 315 err = g.r.Storer.SetReference(plumbing.NewHashReference(ref.Name(), newHash)) 316 if err != nil { 317 return plumbing.ZeroHash, fmt.Errorf("failed to update HEAD: %w", err) 318 } 319 } 320 321 // new hash of commit 322 return newHash, nil 323} 324 325func (g *GitRepo) MergeCheckWithOptions(patchData string, targetBranch string, mo MergeOptions) error { 326 if val, ok := mergeCheckCache.Get(g, patchData, targetBranch); ok { 327 return val 328 } 329 330 patchFile, err := createTemp(patchData) 331 if err != nil { 332 return &ErrMerge{ 333 Message: err.Error(), 334 OtherError: err, 335 } 336 } 337 defer os.Remove(patchFile) 338 339 tmpDir, err := g.cloneTemp(targetBranch) 340 if err != nil { 341 return &ErrMerge{ 342 Message: err.Error(), 343 OtherError: err, 344 } 345 } 346 defer os.RemoveAll(tmpDir) 347 348 tmpRepo, err := PlainOpen(tmpDir) 349 if err != nil { 350 return err 351 } 352 353 result := tmpRepo.applyPatch(patchData, patchFile, mo) 354 mergeCheckCache.Set(g, patchData, targetBranch, result) 355 return result 356} 357 358func (g *GitRepo) MergeWithOptions(patchData string, targetBranch string, opts MergeOptions) error { 359 patchFile, err := createTemp(patchData) 360 if err != nil { 361 return &ErrMerge{ 362 Message: err.Error(), 363 OtherError: err, 364 } 365 } 366 defer os.Remove(patchFile) 367 368 tmpDir, err := g.cloneTemp(targetBranch) 369 if err != nil { 370 return &ErrMerge{ 371 Message: err.Error(), 372 OtherError: err, 373 } 374 } 375 defer os.RemoveAll(tmpDir) 376 377 tmpRepo, err := PlainOpen(tmpDir) 378 if err != nil { 379 return err 380 } 381 382 if err := tmpRepo.applyPatch(patchData, patchFile, opts); err != nil { 383 return err 384 } 385 386 pushCmd := exec.Command("git", "-C", tmpDir, "push") 387 if err := pushCmd.Run(); err != nil { 388 return &ErrMerge{ 389 Message: "failed to push changes to bare repository", 390 OtherError: err, 391 } 392 } 393 394 return nil 395} 396 397func parseGitApplyErrors(errorOutput string) []ConflictInfo { 398 var conflicts []ConflictInfo 399 lines := strings.Split(errorOutput, "\n") 400 401 var currentFile string 402 403 for i := range lines { 404 line := strings.TrimSpace(lines[i]) 405 406 if strings.HasPrefix(line, "error: patch failed:") { 407 parts := strings.SplitN(line, ":", 3) 408 if len(parts) >= 3 { 409 currentFile = strings.TrimSpace(parts[2]) 410 } 411 continue 412 } 413 414 if match := conflictErrorRegex.FindStringSubmatch(line); len(match) >= 4 { 415 if currentFile == "" { 416 currentFile = match[1] 417 } 418 419 conflicts = append(conflicts, ConflictInfo{ 420 Filename: currentFile, 421 Reason: match[3], 422 }) 423 continue 424 } 425 426 if strings.Contains(line, "already exists in working directory") { 427 conflicts = append(conflicts, ConflictInfo{ 428 Filename: currentFile, 429 Reason: "file already exists", 430 }) 431 } else if strings.Contains(line, "does not exist in working tree") { 432 conflicts = append(conflicts, ConflictInfo{ 433 Filename: currentFile, 434 Reason: "file does not exist", 435 }) 436 } else if strings.Contains(line, "patch does not apply") { 437 conflicts = append(conflicts, ConflictInfo{ 438 Filename: currentFile, 439 Reason: "patch does not apply", 440 }) 441 } 442 } 443 444 return conflicts 445}