Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
14 kB · 525 lines
Go
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526package git
import ( "bytes" "crypto/sha256" "fmt" "log" "os" "os/exec" "regexp" "strings"
"github.com/dgraph-io/ristretto" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" "tangled.org/core/gitutil" "tangled.org/core/patchutil" "tangled.org/core/types")
type MergeCheckCache struct { cache *ristretto.Cache}
var ( mergeCheckCache MergeCheckCache conflictErrorRegex = regexp.MustCompile(`^error: (.*):(\d+): (.*)$`))
func init() { cache, _ := ristretto.NewCache(&ristretto.Config{ NumCounters: 1e7, MaxCost: 1 << 30, BufferItems: 64, TtlTickerDurationInSec: 60 * 60 * 24 * 2, // 2 days }) mergeCheckCache = MergeCheckCache{cache}}
func (m *MergeCheckCache) cacheKey(g *GitRepo, patch string, targetBranch string) string { sep := byte(':') hash := sha256.Sum256(fmt.Append([]byte{}, g.path, sep, g.h.String(), sep, patch, sep, targetBranch)) return fmt.Sprintf("%x", hash)}
// we can't cache "mergeable" in risetto, nil is not cacheable//// we use the sentinel value insteadfunc (m *MergeCheckCache) cacheVal(check error) any { if check == nil { return struct{}{} } else { return check }}
func (m *MergeCheckCache) Set(g *GitRepo, patch string, targetBranch string, mergeCheck error) { key := m.cacheKey(g, patch, targetBranch) val := m.cacheVal(mergeCheck) m.cache.Set(key, val, 0)}
func (m *MergeCheckCache) Get(g *GitRepo, patch string, targetBranch string) (error, bool) { key := m.cacheKey(g, patch, targetBranch) if val, ok := m.cache.Get(key); ok { if val == struct{}{} { // cache hit for mergeable return nil, true } else if e, ok := val.(error); ok { // cache hit for merge conflict return e, true } }
// cache miss return nil, false}
type ErrMerge struct { Message string Conflicts []ConflictInfo HasConflict bool OtherError error}
type ConflictInfo struct { Filename string Reason string}
// MergeOptions specifies the configuration for a merge operationtype MergeOptions struct { CommitMessage string CommitBody string AuthorName string AuthorEmail string CommitterName string CommitterEmail string FormatPatch bool}
func (e ErrMerge) Error() string { if e.HasConflict { return fmt.Sprintf("merge failed due to conflicts: %s (%d conflicts)", e.Message, len(e.Conflicts)) } if e.OtherError != nil { return fmt.Sprintf("merge failed: %s: %v", e.Message, e.OtherError) } return fmt.Sprintf("merge failed: %s", e.Message)}
// createTemp creates a temporary patch file in the system temp directory.func createTemp(data string) (string, error) { return createTempIn("", data)}
// createTempIn creates a temporary patch file in dir (empty = system /tmp).func createTempIn(dir string, data string) (string, error) { tmpFile, err := os.CreateTemp(dir, "git-patch-*.patch") if err != nil { return "", fmt.Errorf("failed to create temporary patch file: %w", err) }
if _, err := tmpFile.Write([]byte(data)); err != nil { tmpFile.Close() os.Remove(tmpFile.Name()) return "", fmt.Errorf("failed to write patch data to temporary file: %w", err) }
if err := tmpFile.Close(); err != nil { os.Remove(tmpFile.Name()) return "", fmt.Errorf("failed to close temporary patch file: %w", err) }
return tmpFile.Name(), nil}
func (g *GitRepo) cloneTemp(targetBranch string) (string, error) { tmpDir, err := os.MkdirTemp("", "git-clone-") if err != nil { return "", fmt.Errorf("failed to create temporary directory: %w", err) }
_, err = git.PlainClone(tmpDir, false, &git.CloneOptions{ URL: "file://" + g.path, Depth: 1, SingleBranch: true, ReferenceName: plumbing.NewBranchReferenceName(targetBranch), }) if err != nil { os.RemoveAll(tmpDir) return "", fmt.Errorf("failed to clone repository: %w", err) }
return tmpDir, nil}
func (g *GitRepo) applyPatch(patchData, patchFile string, opts MergeOptions) error { var stderr bytes.Buffer
// wrapCmd optionally sandboxes a command to g.path. wrapCmd := func(cmd *exec.Cmd) (*exec.Cmd, error) { if g.sandbox != nil { var err error cmd, err = g.sandbox.Wrap(g.path, cmd) if err != nil { return nil, err } } else { cmd.Dir = g.path } return gitutil.Contain(cmd), nil }
// configure default git user before merge for _, cfgArgs := range [][]string{ {"-C", g.path, "config", "user.name", opts.CommitterName}, {"-C", g.path, "config", "user.email", opts.CommitterEmail}, {"-C", g.path, "config", "advice.mergeConflict", "false"}, {"-C", g.path, "config", "advice.amWorkDir", "false"}, } { var cfgStderr bytes.Buffer cfgCmd, _ := wrapCmd(exec.Command("git", cfgArgs...)) cfgCmd.Stderr = &cfgStderr if err := cfgCmd.Run(); err != nil { log.Printf("git config %v failed (non-fatal): err=%v stderr=%q", cfgArgs, err, cfgStderr.String()) } }
// if patch is a format-patch, apply using 'git am' if opts.FormatPatch { return g.applyMailbox(patchData) }
// else, apply using 'git apply' and commit it manually applyCmd, err := wrapCmd(exec.Command("git", "-C", g.path, "apply", "--index", patchFile)) if err != nil { return fmt.Errorf("sandbox wrap for git apply: %w", err) } applyCmd.Stderr = &stderr if err := applyCmd.Run(); err != nil { return fmt.Errorf("patch application failed: %s", stderr.String()) }
commitArgs := []string{"-C", g.path, "commit", "--allow-empty"}
// Set author if provided authorName := opts.AuthorName authorEmail := opts.AuthorEmail
if authorName != "" && authorEmail != "" { commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail)) } // else, will default to knot's global user.name & user.email configured via `KNOT_GIT_USER_*` env variables
commitArgs = append(commitArgs, "-m", opts.CommitMessage)
if opts.CommitBody != "" { commitArgs = append(commitArgs, "-m", opts.CommitBody) }
cmd, err := wrapCmd(exec.Command("git", commitArgs...)) if err != nil { return fmt.Errorf("sandbox wrap for git commit: %w", err) } stderr.Reset() cmd.Stderr = &stderr
if err := cmd.Run(); err != nil { conflicts := parseGitApplyErrors(stderr.String()) log.Printf("git commit failed: err=%v stderr=%q", err, stderr.String()) return &ErrMerge{ Message: "patch cannot be applied cleanly", Conflicts: conflicts, HasConflict: len(conflicts) > 0, OtherError: err, } }
return nil}
func (g *GitRepo) applyMailbox(patchData string) error { fps, err := patchutil.ExtractPatches(patchData) if err != nil { return fmt.Errorf("failed to extract patches: %w", err) }
// apply each patch one by one // update the newly created commit object to add the change-id header total := len(fps) for i, p := range fps { newCommit, err := g.applySingleMailbox(p) if err != nil { return err }
log.Printf("applying mailbox patch %d/%d: committed %s\n", i+1, total, newCommit.String()) }
return nil}
func (g *GitRepo) applySingleMailbox(singlePatch types.FormatPatch) (plumbing.Hash, error) { // when sandboxed, create the patch file inside g.path so it is // within the bound directory and visible to the git subprocess. patchDir := "" if g.sandbox != nil { patchDir = g.path } tmpPatch, err := createTempIn(patchDir, singlePatch.Raw) if err != nil { return plumbing.ZeroHash, fmt.Errorf("failed to create temporary patch file for singular mailbox patch: %w", err) }
var stderr bytes.Buffer rawCmd := exec.Command("git", "-C", g.path, "am", tmpPatch) var cmd *exec.Cmd if g.sandbox != nil { cmd, err = g.sandbox.Wrap(g.path, rawCmd) if err != nil { return plumbing.ZeroHash, fmt.Errorf("sandbox wrap for git am: %w", err) } } else { rawCmd.Dir = g.path cmd = rawCmd } gitutil.Contain(cmd) cmd.Stderr = &stderr
head, err := g.r.Head() if err != nil { return plumbing.ZeroHash, err } log.Println("head before apply", head.Hash().String())
if err := cmd.Run(); err != nil { conflicts := parseGitApplyErrors(stderr.String()) log.Printf("git am failed: err=%v stderr=%q", err, stderr.String()) return plumbing.ZeroHash, &ErrMerge{ Message: "patch cannot be applied cleanly", Conflicts: conflicts, HasConflict: len(conflicts) > 0, OtherError: err, } }
refreshed, err := PlainOpen(g.path) if err != nil { return plumbing.ZeroHash, fmt.Errorf("failed to refresh repository state: %w", err) } *g = *refreshed
head, err = g.r.Head() if err != nil { return plumbing.ZeroHash, err } log.Println("head after apply", head.Hash().String())
newHash := head.Hash() if changeId, err := singlePatch.ChangeId(); err != nil { // no change ID } else if updatedHash, err := g.setChangeId(head.Hash(), changeId); err != nil { return plumbing.ZeroHash, err } else { newHash = updatedHash }
return newHash, nil}
func (g *GitRepo) setChangeId(hash plumbing.Hash, changeId string) (plumbing.Hash, error) { log.Printf("updating change ID of %s to %s\n", hash.String(), changeId) obj, err := g.r.CommitObject(hash) if err != nil { return plumbing.ZeroHash, fmt.Errorf("failed to get commit object for hash %s: %w", hash.String(), err) }
// write the change-id header obj.ExtraHeaders["change-id"] = []byte(changeId)
// create a new object dest := g.r.Storer.NewEncodedObject() if err := obj.Encode(dest); err != nil { return plumbing.ZeroHash, fmt.Errorf("failed to create new object: %w", err) }
// store the new object newHash, err := g.r.Storer.SetEncodedObject(dest) if err != nil { return plumbing.ZeroHash, fmt.Errorf("failed to store new object: %w", err) }
log.Printf("hash changed from %s to %s\n", obj.Hash.String(), newHash.String())
// find the branch that HEAD is pointing to ref, err := g.r.Head() if err != nil { return plumbing.ZeroHash, fmt.Errorf("failed to fetch HEAD: %w", err) }
// and update that branch to point to new commit if ref.Name().IsBranch() { err = g.r.Storer.SetReference(plumbing.NewHashReference(ref.Name(), newHash)) if err != nil { return plumbing.ZeroHash, fmt.Errorf("failed to update HEAD: %w", err) } }
// new hash of commit return newHash, nil}
func (g *GitRepo) MergeCheckWithOptions(patchData string, targetBranch string, mo MergeOptions) error { if val, ok := mergeCheckCache.Get(g, patchData, targetBranch); ok { return val }
tmpDir, err := g.cloneTemp(targetBranch) if err != nil { return &ErrMerge{ Message: err.Error(), OtherError: err, } } defer os.RemoveAll(tmpDir)
// when sandboxed, create the patch file inside tmpDir so it is // visible to the git subprocess. patchDir := "" if g.sandbox != nil { patchDir = tmpDir } patchFile, err := createTempIn(patchDir, patchData) if err != nil { return &ErrMerge{ Message: err.Error(), OtherError: err, } } defer os.Remove(patchFile)
tmpRepo, err := PlainOpen(tmpDir) if err != nil { return err } if g.sandbox != nil { tmpRepo = tmpRepo.WithSandbox(g.sandbox) }
result := tmpRepo.applyPatch(patchData, patchFile, mo) mergeCheckCache.Set(g, patchData, targetBranch, result) return result}
func (g *GitRepo) MergeWithOptions(patchData string, targetBranch string, opts MergeOptions) error { tmpDir, err := g.cloneTemp(targetBranch) if err != nil { return &ErrMerge{ Message: err.Error(), OtherError: err, } } defer os.RemoveAll(tmpDir)
// when sandboxed, create the patch file inside tmpDir so it is // visible to the git subprocess. patchDir := "" if g.sandbox != nil { patchDir = tmpDir } patchFile, err := createTempIn(patchDir, patchData) if err != nil { return &ErrMerge{ Message: err.Error(), OtherError: err, } } defer os.Remove(patchFile)
tmpRepo, err := PlainOpen(tmpDir) if err != nil { return err } if g.sandbox != nil { tmpRepo = tmpRepo.WithSandbox(g.sandbox) }
if err := tmpRepo.applyPatch(patchData, patchFile, opts); err != nil { return err }
pushCmd := exec.Command("git", "-C", tmpDir, "push") if g.sandbox != nil { // the push needs access to both tmpDir (source) and g.path (target bare repo). pushCmd, err = g.sandbox.WrapMulti([]string{tmpDir, g.path}, pushCmd) if err != nil { return &ErrMerge{ Message: "sandbox wrap for git push failed", OtherError: err, } } } else { pushCmd.Dir = tmpDir } gitutil.Contain(pushCmd) if err := pushCmd.Run(); err != nil { return &ErrMerge{ Message: "failed to push changes to bare repository", OtherError: err, } }
return nil}
func parseGitApplyErrors(errorOutput string) []ConflictInfo { var conflicts []ConflictInfo lines := strings.Split(errorOutput, "\n")
var currentFile string
for i := range lines { line := strings.TrimSpace(lines[i])
if strings.HasPrefix(line, "error: patch failed:") { parts := strings.SplitN(line, ":", 3) if len(parts) >= 3 { currentFile = strings.TrimSpace(parts[2]) } continue }
if match := conflictErrorRegex.FindStringSubmatch(line); len(match) >= 4 { if currentFile == "" { currentFile = match[1] }
conflicts = append(conflicts, ConflictInfo{ Filename: currentFile, Reason: match[3], }) continue }
if strings.Contains(line, "already exists in working directory") { conflicts = append(conflicts, ConflictInfo{ Filename: currentFile, Reason: "file already exists", }) } else if strings.Contains(line, "does not exist in working tree") { conflicts = append(conflicts, ConflictInfo{ Filename: currentFile, Reason: "file does not exist", }) } else if strings.Contains(line, "patch does not apply") { conflicts = append(conflicts, ConflictInfo{ Filename: currentFile, Reason: "patch does not apply", }) } }
return conflicts}