New Sign in

fierj

Public

Tiny personal git forge

← fierj / patch.go
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"log/slog"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"sync"
	"time"
)

// patchLocks protects concurrent read-modify-write per patch file.
var patchLocks sync.Map // map[string]*sync.Mutex

func lockPatch(patchPath string) func() {
	mu := &sync.Mutex{}
	actual, _ := patchLocks.LoadOrStore(patchPath, mu)
	mu = actual.(*sync.Mutex)
	mu.Lock()
	return func() { mu.Unlock() }
}

// Patch represents an uploaded patch or a branch-based merge proposal.
type Patch struct {
	ID         string `json:"id"`
	State      string `json:"state"` // "open", "merged", "closed"
	Title      string `json:"title"`
	Body       string `json:"body"`
	Author     string `json:"author"`
	AuthorName string `json:"author_name"`
	Created    string `json:"created"`
	Updated    string `json:"updated"`
	// Branch-based
	Branch string `json:"branch,omitempty"`
	// File-based
	PatchFile string `json:"patch_file,omitempty"`
}

func patchesDir(repoPath string) string {
	return filepath.Join(repoPath, ".forge", "patches")
}

func patchDataDir(repoPath string) string {
	return filepath.Join(repoPath, ".forge", "patch-files")
}

// createPatchFromBranch creates a patch entry for an internal branch.
func createPatchFromBranch(repoPath, branch, title, body, author, authorName string) (*Patch, error) {
	dir := patchesDir(repoPath)
	if err := os.MkdirAll(dir, 0755); err != nil {
		return nil, err
	}

	now := time.Now().UTC().Format(time.RFC3339)
	p := &Patch{
		ID:         newULID(),
		State:      "open",
		Title:      title,
		Body:       body,
		Author:     author,
		AuthorName: authorName,
		Branch:     branch,
		Created:    now,
		Updated:    now,
	}
	return p, writePatch(repoPath, p)
}

// createPatchFromFile creates a patch entry from an uploaded .patch file.
func createPatchFromFile(repoPath, title, body, author, authorName string, patchContent []byte) (*Patch, error) {
	dir := patchesDir(repoPath)
	fileDir := patchDataDir(repoPath)
	if err := os.MkdirAll(dir, 0755); err != nil {
		return nil, err
	}
	if err := os.MkdirAll(fileDir, 0755); err != nil {
		return nil, err
	}

	id := newULID()
	now := time.Now().UTC().Format(time.RFC3339)

	// Save the patch file
	patchFileName := id + ".patch"
	if err := os.WriteFile(filepath.Join(fileDir, patchFileName), patchContent, 0644); err != nil {
		return nil, err
	}

	p := &Patch{
		ID:         id,
		State:      "open",
		Title:      title,
		Body:       body,
		Author:     author,
		AuthorName: authorName,
		PatchFile:  patchFileName,
		Created:    now,
		Updated:    now,
	}
	return p, writePatch(repoPath, p)
}

// listPatches returns all patches filtered by state.
func listPatches(repoPath, state string) ([]Patch, error) {
	dir := patchesDir(repoPath)
	entries, err := os.ReadDir(dir)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, nil
		}
		return nil, err
	}

	var patches []Patch
	for _, e := range entries {
		if !strings.HasSuffix(e.Name(), ".json") {
			continue
		}
		data, err := os.ReadFile(filepath.Join(dir, e.Name()))
		if err != nil {
			continue
		}
		var p Patch
		if err := json.Unmarshal(data, &p); err != nil {
			continue
		}
		if state == "" || p.State == state {
			patches = append(patches, p)
		}
	}
	return patches, nil
}

// loadPatch reads a single patch.
func loadPatch(repoPath, patchID string) (*Patch, error) {
	data, err := os.ReadFile(filepath.Join(patchesDir(repoPath), patchID+".json"))
	if err != nil {
		return nil, err
	}
	var p Patch
	if err := json.Unmarshal(data, &p); err != nil {
		return nil, err
	}
	return &p, nil
}

// patchDiff returns the diff for a patch (either branch-based or file-based).
func patchDiff(repoPath string, p *Patch) (string, error) {
	if p.Branch != "" {
		if !isValidRef(p.Branch) {
			return "", fmt.Errorf("invalid branch name")
		}
		defBranch := defaultBranch(repoPath)
		cmd := exec.Command("git", "diff", defBranch+"..."+p.Branch)
		cmd.Dir = repoPath
		out, err := cmd.Output()
		if err != nil {
			return "", err
		}
		return string(out), nil
	}
	if p.PatchFile != "" {
		data, err := os.ReadFile(filepath.Join(patchDataDir(repoPath), p.PatchFile))
		if err != nil {
			return "", err
		}
		return string(data), nil
	}
	return "", fmt.Errorf("no diff source")
}

// mergePatch merges a branch-based patch or applies a file-based patch.
func mergePatch(repoPath string, p *Patch) error {
	patchPath := filepath.Join(patchesDir(repoPath), p.ID+".json")
	unlock := lockPatch(patchPath)
	defer unlock()

	// Re-read patch under lock to get fresh state
	current, err := loadPatch(repoPath, p.ID)
	if err != nil {
		return err
	}
	if current.State != "open" {
		return fmt.Errorf("patch is not open")
	}
	if current.Branch != "" {
		target := defaultBranch(repoPath)
		if err := fastForwardMerge(repoPath, target, current.Branch); err != nil {
			slog.Error("patch merge failed", "repo", filepath.Base(repoPath), "branch", current.Branch, "error", err)
			return err
		}
	} else if current.PatchFile != "" {
		patchFilePath := filepath.Join(patchDataDir(repoPath), current.PatchFile)
		// git am requires a full working tree. Clone the bare repo into a
		// temp directory, apply the patch, and push back.
		tmpDir, err := os.MkdirTemp("", "fierj-merge-")
		if err != nil {
			return fmt.Errorf("create temp dir: %w", err)
		}
		defer os.RemoveAll(tmpDir)

		target := defaultBranch(repoPath)
		var stderr bytes.Buffer

		// Clone the bare repo into a working tree.
		clone := exec.Command("git", "clone", "--branch", target, repoPath, tmpDir)
		clone.Stderr = &stderr
		if err := clone.Run(); err != nil {
			return fmt.Errorf("clone for merge: %s", strings.TrimSpace(stderr.String()))
		}

		// Apply the mailbox-format patch.
		stderr.Reset()
		am := exec.Command("git", "am", patchFilePath)
		am.Dir = tmpDir
		am.Stderr = &stderr
		if err := am.Run(); err != nil {
			return fmt.Errorf("git am failed: %s", strings.TrimSpace(stderr.String()))
		}

		// Push the result back to the bare repo.
		stderr.Reset()
		push := exec.Command("git", "push", "origin", "HEAD:refs/heads/"+target)
		push.Dir = tmpDir
		push.Stderr = &stderr
		if err := push.Run(); err != nil {
			return fmt.Errorf("push after am: %s", strings.TrimSpace(stderr.String()))
		}
	}

	current.State = "merged"
	current.Updated = time.Now().UTC().Format(time.RFC3339)
	return writePatch(repoPath, current)
}

// closePatch closes a patch without merging.
func closePatch(repoPath, patchID string) error {
	patchPath := filepath.Join(patchesDir(repoPath), patchID+".json")
	unlock := lockPatch(patchPath)
	defer unlock()

	p, err := loadPatch(repoPath, patchID)
	if err != nil {
		return err
	}
	p.State = "closed"
	p.Updated = time.Now().UTC().Format(time.RFC3339)
	return writePatch(repoPath, p)
}

func writePatch(repoPath string, p *Patch) error {
	dir := patchesDir(repoPath)
	os.MkdirAll(dir, 0755)
	data, err := json.MarshalIndent(p, "", "  ")
	if err != nil {
		return err
	}
	target := filepath.Join(dir, p.ID+".json")
	tmp := target + ".tmp"
	if err := os.WriteFile(tmp, data, 0644); err != nil {
		return err
	}
	return os.Rename(tmp, target)
}

// fastForwardMerge updates target to point to branch if target is an ancestor
// of branch (pure fast-forward). Works in bare repositories.
func fastForwardMerge(repoPath, target, branch string) error {
	if !isValidRef(target) || !isValidRef(branch) {
		return fmt.Errorf("invalid branch name")
	}
	// Check that target is an ancestor of branch.
	cmd := exec.Command("git", "merge-base", "--is-ancestor", "refs/heads/"+target, "refs/heads/"+branch)
	cmd.Dir = repoPath
	if err := cmd.Run(); err != nil {
		return fmt.Errorf("%q is not ahead of %q — rebase the branch first", branch, target)
	}
	// Fast-forward: update the target ref to point to the branch.
	cmd = exec.Command("git", "update-ref", "refs/heads/"+target, "refs/heads/"+branch)
	cmd.Dir = repoPath
	var stderr bytes.Buffer
	cmd.Stderr = &stderr
	if err := cmd.Run(); err != nil {
		return fmt.Errorf("update-ref failed: %s", strings.TrimSpace(stderr.String()))
	}
	// Delete the merged branch (it's now reachable from target).
	cmd = exec.Command("git", "update-ref", "-d", "refs/heads/"+branch)
	cmd.Dir = repoPath
	cmd.Run() // best-effort; ignore errors
	return nil
}

// headTarget returns the symbolic-ref target of HEAD even if the branch
// doesn't exist yet. Returns "" if HEAD is detached or unreadable.
func headTarget(repoPath string) string {
	name := strings.TrimSuffix(filepath.Base(repoPath), ".git")
	g := &Git{Dir: filepath.Dir(repoPath), Name: name}
	out, err := g.cmd("symbolic-ref", "--short", "HEAD")
	if err != nil {
		return ""
	}
	return strings.TrimSpace(out)
}

// nonDefaultBranches returns branches that aren't the primary branch
// (the one HEAD points to, even if it doesn't exist yet). Also excludes
// wip/ branches. These are candidates for patch submission.
func nonDefaultBranches(repoPath string) []string {
	head := headTarget(repoPath)
	all := listBranches(repoPath)
	var result []string
	for _, b := range all {
		if b == head || strings.HasPrefix(b, "wip/") {
			continue
		}
		result = append(result, b)
	}
	return result
}