Lindenii Project Forge
Login

server

Lindenii Forge’s main backend daemon
Commit info
ID
c32389d7d54f3fe66d32f849c02c5e75b7d476c8
Author
Runxi Yu <me@runxiyu.org>
Author date
Wed, 02 Apr 2025 08:49:03 +0800
Committer
Runxi Yu <me@runxiyu.org>
Committer date
Wed, 02 Apr 2025 09:24:37 +0800
Actions
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
}