Lindenii Project Forge
LMTP: Actually apply patches from email
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org> package main import ( "bytes" "context" "encoding/hex" "errors" "os" "os/exec" "path" "sort" "strings" ) func writeTree(ctx context.Context, repoPath string, entries []treeEntry) (string, error) { var buf bytes.Buffer // Must sort.Slice(entries, func(i, j int) bool { return entries[i].name < entries[j].name }) for _, e := range entries { buf.WriteString(e.mode) buf.WriteByte(' ') buf.WriteString(e.name) buf.WriteByte(0) buf.Write(e.sha) } cmd := exec.CommandContext(ctx, "git", "hash-object", "-w", "-t", "tree", "--stdin") cmd.Env = append(os.Environ(), "GIT_DIR="+repoPath) cmd.Stdin = &buf var out bytes.Buffer cmd.Stdout = &out if err := cmd.Run(); err != nil { return "", err } return strings.TrimSpace(out.String()), nil } func buildTreeRecursive(ctx context.Context, repoPath string, baseTree string, updates map[string][]byte) (string, error) { treeCache := make(map[string][]treeEntry) var walk func(string, string) error walk = func(prefix, sha string) error { cmd := exec.CommandContext(ctx, "git", "cat-file", "tree", sha) cmd.Env = append(os.Environ(), "GIT_DIR="+repoPath) var out bytes.Buffer cmd.Stdout = &out if err := cmd.Run(); err != nil { return err } data := out.Bytes() i := 0 var entries []treeEntry for i < len(data) { modeEnd := bytes.IndexByte(data[i:], ' ') if modeEnd < 0 { return errors.New("invalid tree format") } mode := string(data[i : i+modeEnd]) i += modeEnd + 1 nameEnd := bytes.IndexByte(data[i:], 0) if nameEnd < 0 { return errors.New("missing null after filename") } name := string(data[i : i+nameEnd]) i += nameEnd + 1 if i+20 > len(data) { return errors.New("unexpected EOF in SHA") } shaBytes := data[i : i+20] i += 20 entries = append(entries, treeEntry{ mode: mode, name: name, sha: shaBytes, }) if mode == "40000" { subPrefix := path.Join(prefix, name) if err := walk(subPrefix, hex.EncodeToString(shaBytes)); err != nil { return err } } } treeCache[prefix] = entries return nil } if err := walk("", baseTree); err != nil { return "", err } for filePath, blobSha := range updates { parts := strings.Split(filePath, "/") dir := strings.Join(parts[:len(parts)-1], "/") name := parts[len(parts)-1] entries := treeCache[dir] found := false for i, e := range entries { if e.name == name { if blobSha == nil { // Remove TODO entries = append(entries[:i], entries[i+1:]...) } else { entries[i].sha = blobSha } found = true break } } if !found && blobSha != nil { entries = append(entries, treeEntry{ mode: "100644", name: name, sha: blobSha, }) } treeCache[dir] = entries } built := make(map[string][]byte) var build func(string) ([]byte, error) build = func(prefix string) ([]byte, error) { entries := treeCache[prefix] for i, e := range entries { if e.mode == "40000" { subPrefix := path.Join(prefix, e.name) if sha, ok := built[subPrefix]; ok { entries[i].sha = sha continue } newShaStr, err := build(subPrefix) if err != nil { return nil, err } entries[i].sha = newShaStr } } shaStr, err := writeTree(ctx, repoPath, entries) if err != nil { return nil, err } shaBytes, err := hex.DecodeString(shaStr) if err != nil { return nil, err } built[prefix] = shaBytes return shaBytes, nil } rootShaBytes, err := build("") if err != nil { return "", err } return hex.EncodeToString(rootShaBytes), nil } type treeEntry struct { mode string // like "100644" name string // individual name sha []byte }
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org> package main import ( "bytes"
// "crypto/rand" // "fmt"
"crypto/rand" "encoding/hex"
"os" "os/exec"
"strings" "time"
"github.com/bluekeyes/go-gitdiff/gitdiff" "github.com/emersion/go-message" "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object"
) func lmtpHandlePatch(session *lmtpSession, groupPath []string, repoName string, email *message.Entity) (err error) { var diffFiles []*gitdiff.File var preamble string if diffFiles, preamble, err = gitdiff.Parse(email.Body); err != nil { return }
var header *gitdiff.PatchHeader if header, err = gitdiff.ParsePatchHeader(preamble); err != nil { return }
var repo *git.Repository var fsPath string repo, _, _, fsPath, err = openRepo(session.ctx, groupPath, repoName) if err != nil { return }
var headRef *plumbing.Reference if headRef, err = repo.Head(); err != nil {
headRef, err := repo.Head() if err != nil {
return }
var headCommit *object.Commit if headCommit, err = repo.CommitObject(headRef.Hash()); err != nil {
headCommit, err := repo.CommitObject(headRef.Hash()) if err != nil {
return }
var headTree *object.Tree if headTree, err = headCommit.Tree(); err != nil {
headTree, err := headCommit.Tree() if err != nil {
return }
// TODO: Try to not shell out
headTreeHash := headTree.Hash.String()
blobUpdates := make(map[string][]byte)
for _, diffFile := range diffFiles {
var sourceFile *object.File if sourceFile, err = headTree.File(diffFile.OldName); err != nil {
sourceFile, err := headTree.File(diffFile.OldName) if err != nil {
return err }
var sourceString string if sourceString, err = sourceFile.Contents(); err != nil {
sourceString, err := sourceFile.Contents() if err != nil {
return err }
hashBuf := bytes.Buffer{} patchedBuf := bytes.Buffer{}
sourceBuf := bytes.NewReader(stringToBytes(sourceString))
if err = gitdiff.Apply(&patchedBuf, sourceBuf, diffFile); err != nil {
var patchedBuf bytes.Buffer if err := gitdiff.Apply(&patchedBuf, sourceBuf, diffFile); err != nil {
return err }
proc := exec.CommandContext(session.ctx, "git", "hash-object", "-w", "-t", "blob", "--stdin") proc.Env = append(os.Environ(), "GIT_DIR="+fsPath) proc.Stdout = &hashBuf proc.Stdin = &patchedBuf if err = proc.Start(); err != nil {
var hashBuf bytes.Buffer // It's really difficult to do this via go-git so we're just // going to use upstream git for now. // TODO cmd := exec.CommandContext(session.ctx, "git", "hash-object", "-w", "-t", "blob", "--stdin") cmd.Env = append(os.Environ(), "GIT_DIR="+fsPath) cmd.Stdout = &hashBuf cmd.Stdin = &patchedBuf if err := cmd.Run(); err != nil {
return err }
if err = proc.Wait(); err != nil {
newHashStr := strings.TrimSpace(hashBuf.String()) newHash, err := hex.DecodeString(newHashStr) if err != nil {
return err }
newHash := hashBuf.Bytes() if len(newHash) != 20*2+1 { // TODO: Hardcoded from the size of plumbing.Hash panic("unexpected hash size")
blobUpdates[diffFile.NewName] = newHash if diffFile.NewName != diffFile.OldName { blobUpdates[diffFile.OldName] = nil // Mark for deletion.
}
// TODO: Add to tree
}
// contribBranchName := rand.Text()
newTreeSha, err := buildTreeRecursive(session.ctx, fsPath, headTreeHash, blobUpdates) if err != nil { return err }
// TODO: Store the branch
commitMsg := header.Title if header.Body != "" { commitMsg += "\n\n" + header.Body }
// fmt.Println(repo, diffFiles, preamble) _ = preamble
env := append(os.Environ(), "GIT_DIR="+fsPath, "GIT_AUTHOR_NAME="+header.Author.Name, "GIT_AUTHOR_EMAIL="+header.Author.Email, "GIT_AUTHOR_DATE="+header.AuthorDate.Format(time.RFC3339), ) commitCmd := exec.CommandContext(session.ctx, "git", "commit-tree", newTreeSha, "-p", headCommit.Hash.String(), "-m", commitMsg) commitCmd.Env = env var commitOut bytes.Buffer commitCmd.Stdout = &commitOut if err := commitCmd.Run(); err != nil { return err } newCommitSha := strings.TrimSpace(commitOut.String()) newBranchName := rand.Text() refCmd := exec.CommandContext(session.ctx, "git", "update-ref", "refs/heads/contrib/"+newBranchName, newCommitSha) //#nosec G204 refCmd.Env = append(os.Environ(), "GIT_DIR="+fsPath) if err := refCmd.Run(); err != nil { return err }
return nil }