fierj
PublicTiny personal git forge
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)
cmd := exec.Command("git", "am", patchFilePath)
cmd.Dir = repoPath
if err := cmd.Run(); err != nil {
return fmt.Errorf("git am failed: %w", err)
}
}
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()))
}
return nil
}
// nonDefaultBranches returns branches that aren't the default (potential patches).
func nonDefaultBranches(repoPath string) []string {
def := defaultBranch(repoPath)
all := listBranches(repoPath)
var result []string
for _, b := range all {
if b != def && !strings.HasPrefix(b, "wip/") {
result = append(result, b)
}
}
return result
}