New Sign in

fierj

Public

Tiny personal git forge

3cff24d1369258910de751ad11024a55d9c315a3
diff --git a/git.go b/git.go
index 6c3967f..7ca37c4 100644
--- a/git.go
+++ b/git.go
@@ -339,6 +339,20 @@ func (g *Git) Readme(ref, dir string) string {
 	return ""
 }
 
+// defaultBranch returns the default branch for a bare repo at repoPath.
+func defaultBranch(repoPath string) string {
+	name := strings.TrimSuffix(filepath.Base(repoPath), ".git")
+	g := &Git{Dir: filepath.Dir(repoPath), Name: name}
+	return g.DefaultBranch()
+}
+
+// listBranches returns all branches for a bare repo at repoPath.
+func listBranches(repoPath string) []string {
+	name := strings.TrimSuffix(filepath.Base(repoPath), ".git")
+	g := &Git{Dir: filepath.Dir(repoPath), Name: name}
+	return g.Branches()
+}
+
 func (g *Git) LastCommit(ref, path string) *Commit {
 	out, err := g.cmd("log", "-1", "--format=%H%n%an%n%ar%n%s", ref, "--", path)
 	if err != nil {
diff --git a/handlers_patches.go b/handlers_patches.go
new file mode 100644
index 0000000..0c4cd4c
--- /dev/null
+++ b/handlers_patches.go
@@ -0,0 +1,172 @@
+package main
+
+import (
+	"fmt"
+	"html/template"
+	"io"
+	"net/http"
+	"strings"
+)
+
+func PatchesList(cfg Config, tmpl *template.Template) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
+		if !checkRepoAccess(w, r, git) {
+			return
+		}
+		state := r.URL.Query().Get("state")
+		if state == "" {
+			state = "open"
+		}
+		ref := git.DefaultBranch()
+		rp := repoPath(cfg, git.Name)
+		patches, _ := listPatches(rp, state)
+		tmpl.ExecuteTemplate(w, "patches.html", withUser(r, map[string]any{
+			"Repo":         git.Repo(),
+			"IsPrivate":    git.IsPrivate(),
+			"Ref":          ref,
+			"Patches":      patches,
+			"State":        state,
+			"FreeBranches": nonDefaultBranches(rp),
+			"Description":  git.Description(),
+			"Branches":     git.Branches(),
+			"Tags":         git.Tags(),
+			"Commits":      git.CommitCount(ref),
+			"ActiveTab":    "patches",
+		}))
+	}
+}
+
+func PatchNewGet(cfg Config, tmpl *template.Template) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
+		if !checkRepoAccess(w, r, git) {
+			return
+		}
+		ref := git.DefaultBranch()
+		rp := repoPath(cfg, git.Name)
+		tmpl.ExecuteTemplate(w, "patch_new.html", withUser(r, map[string]any{
+			"Repo":         git.Repo(),
+			"IsPrivate":    git.IsPrivate(),
+			"Ref":          ref,
+			"FreeBranches": nonDefaultBranches(rp),
+			"Description":  git.Description(),
+			"Branches":     git.Branches(),
+			"Tags":         git.Tags(),
+			"Commits":      git.CommitCount(ref),
+			"ActiveTab":    "patches",
+		}))
+	}
+}
+
+func PatchNewPost(cfg Config, tmpl *template.Template) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
+		if !checkRepoAccess(w, r, git) {
+			return
+		}
+		title := strings.TrimSpace(r.FormValue("title"))
+		if title == "" {
+			renderError(w, tmpl, http.StatusBadRequest, "Title is required")
+			return
+		}
+		body := strings.TrimSpace(r.FormValue("body"))
+		author, authorName := threadAuthor(r)
+		rp := repoPath(cfg, git.Name)
+
+		var p *Patch
+		var err error
+
+		branch := strings.TrimSpace(r.FormValue("branch"))
+		if branch != "" {
+			p, err = createPatchFromBranch(rp, branch, title, body, author, authorName)
+		} else {
+			// Try file upload
+			file, header, ferr := r.FormFile("patch_file")
+			if ferr != nil {
+				renderError(w, tmpl, http.StatusBadRequest, "Either a branch or a .patch file is required")
+				return
+			}
+			defer file.Close()
+			patchContent, rerr := io.ReadAll(file)
+			if rerr != nil || len(patchContent) == 0 {
+				renderError(w, tmpl, http.StatusBadRequest, "Failed to read patch file")
+				return
+			}
+			_ = header
+			p, err = createPatchFromFile(rp, title, body, author, authorName, patchContent)
+		}
+		if err != nil {
+			renderError(w, tmpl, http.StatusInternalServerError, err.Error())
+			return
+		}
+		http.Redirect(w, r, fmt.Sprintf("/%s/patches/%s", git.Name, p.ID), http.StatusSeeOther)
+	}
+}
+
+func PatchView(cfg Config, tmpl *template.Template) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
+		if !checkRepoAccess(w, r, git) {
+			return
+		}
+		patchID := r.PathValue("patchId")
+		rp := repoPath(cfg, git.Name)
+		p, err := loadPatch(rp, patchID)
+		if err != nil {
+			renderError(w, tmpl, http.StatusNotFound, "Patch not found")
+			return
+		}
+		diff, _ := patchDiff(rp, p)
+		ref := git.DefaultBranch()
+		tmpl.ExecuteTemplate(w, "patch_view.html", withUser(r, map[string]any{
+			"Repo":        git.Repo(),
+			"IsPrivate":   git.IsPrivate(),
+			"Ref":         ref,
+			"Patch":       p,
+			"Diff":        diff,
+			"Description": git.Description(),
+			"Branches":    git.Branches(),
+			"Tags":        git.Tags(),
+			"Commits":     git.CommitCount(ref),
+			"ActiveTab":   "patches",
+		}))
+	}
+}
+
+func PatchMergePost(cfg Config, tmpl *template.Template) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
+		if !checkRepoAccess(w, r, git) {
+			return
+		}
+		patchID := r.PathValue("patchId")
+		rp := repoPath(cfg, git.Name)
+		p, err := loadPatch(rp, patchID)
+		if err != nil {
+			renderError(w, tmpl, http.StatusNotFound, "Patch not found")
+			return
+		}
+		if err := mergePatch(rp, p); err != nil {
+			renderError(w, tmpl, http.StatusInternalServerError, err.Error())
+			return
+		}
+		http.Redirect(w, r, fmt.Sprintf("/%s/patches/%s", git.Name, patchID), http.StatusSeeOther)
+	}
+}
+
+func PatchClosePost(cfg Config, tmpl *template.Template) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
+		if !checkRepoAccess(w, r, git) {
+			return
+		}
+		patchID := r.PathValue("patchId")
+		rp := repoPath(cfg, git.Name)
+		if err := closePatch(rp, patchID); err != nil {
+			renderError(w, tmpl, http.StatusInternalServerError, err.Error())
+			return
+		}
+		http.Redirect(w, r, fmt.Sprintf("/%s/patches/%s", git.Name, patchID), http.StatusSeeOther)
+	}
+}
diff --git a/main.go b/main.go
index 098f36e..92d0671 100644
--- a/main.go
+++ b/main.go
@@ -75,6 +75,14 @@ func main() {
 	mux.HandleFunc("POST /{repo}/threads/{threadId}/close", ThreadClosePost(cfg, tmpl))
 	mux.HandleFunc("POST /{repo}/threads/{threadId}/reopen", ThreadReopenPost(cfg, tmpl))
 
+	// Patches
+	mux.HandleFunc("GET /{repo}/patches", PatchesList(cfg, tmpl))
+	mux.HandleFunc("GET /{repo}/patches/new", PatchNewGet(cfg, tmpl))
+	mux.HandleFunc("POST /{repo}/patches/new", PatchNewPost(cfg, tmpl))
+	mux.HandleFunc("GET /{repo}/patches/{patchId}", PatchView(cfg, tmpl))
+	mux.HandleFunc("POST /{repo}/patches/{patchId}/merge", PatchMergePost(cfg, tmpl))
+	mux.HandleFunc("POST /{repo}/patches/{patchId}/close", PatchClosePost(cfg, tmpl))
+
 	// Wrap with auth middleware and setup redirect.
 	var handler http.Handler = mux
 	if len(cookieSecret) > 0 {
diff --git a/patches.go b/patches.go
new file mode 100644
index 0000000..4681ec4
--- /dev/null
+++ b/patches.go
@@ -0,0 +1,252 @@
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"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 != "" {
+		// Diff between default branch and patch branch
+		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 != "" {
+		cmd := exec.Command("git", "merge", "--ff-only", current.Branch)
+		cmd.Dir = repoPath
+		if err := cmd.Run(); err != nil {
+			// Try non-ff merge
+			cmd = exec.Command("git", "merge", "--no-ff", "-m", "Merge "+current.Branch, current.Branch)
+			cmd.Dir = repoPath
+			if err := cmd.Run(); err != nil {
+				return fmt.Errorf("merge failed: %w", 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)
+}
+
+// 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
+}
diff --git a/templates/patch_new.html b/templates/patch_new.html
new file mode 100644
index 0000000..3c4527a
--- /dev/null
+++ b/templates/patch_new.html
@@ -0,0 +1,40 @@
+{{template "head" .}}
+{{define "title"}}new patch - {{.Repo}} - fierj{{end}}
+{{template "repo-header" .}}
+{{template "repo-tabs" .}}
+
+<div class="thread-new-form">
+    <h2>New Patch</h2>
+    <form method="POST" action="/{{.Repo}}/patches/new" enctype="multipart/form-data">
+        <div class="form-group">
+            <label for="title">Title</label>
+            <input type="text" id="title" name="title" placeholder="Brief description of the change" required>
+        </div>
+        <div class="form-group">
+            <label for="body">Description</label>
+            <textarea id="body" name="body" rows="4" placeholder="What does this change do?"></textarea>
+        </div>
+
+        {{if .FreeBranches}}
+        <div class="form-group">
+            <label for="branch">From branch (internal)</label>
+            <select id="branch" name="branch">
+                <option value="">— select branch or upload patch file —</option>
+                {{range .FreeBranches}}
+                <option value="{{.}}">{{.}}</option>
+                {{end}}
+            </select>
+        </div>
+        <p style="color: var(--text-secondary); font-size: 0.85rem; margin: var(--space-sm) 0;">— or —</p>
+        {{end}}
+
+        <div class="form-group">
+            <label for="patch_file">Upload .patch file (external)</label>
+            <input type="file" id="patch_file" name="patch_file" accept=".patch,.diff">
+        </div>
+
+        <button type="submit" class="btn btn-primary">Submit patch</button>
+    </form>
+</div>
+{{template "foot" .}}
+
diff --git a/templates/patch_view.html b/templates/patch_view.html
new file mode 100644
index 0000000..813759a
--- /dev/null
+++ b/templates/patch_view.html
@@ -0,0 +1,44 @@
+{{template "head" .}}
+{{define "title"}}{{.Patch.Title}} - {{.Repo}} - fierj{{end}}
+{{template "repo-header" .}}
+{{template "repo-tabs" .}}
+
+<div class="thread-detail">
+    <div class="thread-detail-header">
+        <h2>{{.Patch.Title}}</h2>
+        <span class="thread-state thread-state-{{.Patch.State}}">{{.Patch.State}}</span>
+    </div>
+    <p class="thread-meta-line">
+        {{.Patch.AuthorName}} ·
+        {{if .Patch.Branch}}branch: <code>{{.Patch.Branch}}</code> · {{end}}
+        {{if .Patch.PatchFile}}file: <code>{{.Patch.PatchFile}}</code> · {{end}}
+        {{timeAgo .Patch.Created}}
+    </p>
+</div>
+
+{{if .Patch.Body}}
+<div class="thread-comment">
+    <div class="thread-comment-header">
+        <strong>{{.Patch.AuthorName}}</strong>
+        <span>{{timeAgo .Patch.Created}}</span>
+    </div>
+    <div class="thread-comment-body">{{.Patch.Body}}</div>
+</div>
+{{end}}
+
+{{if .Diff}}
+<pre><code>{{.Diff}}</code></pre>
+{{end}}
+
+{{if eq .Patch.State "open"}}
+<div class="thread-actions" style="margin-top: var(--space-lg);">
+    <form method="POST" action="/{{.Repo}}/patches/{{.Patch.ID}}/merge" style="display:inline;">
+        <button type="submit" class="btn btn-primary">Merge</button>
+    </form>
+    <form method="POST" action="/{{.Repo}}/patches/{{.Patch.ID}}/close" style="display:inline;">
+        <button type="submit" class="btn btn-secondary">Close without merging</button>
+    </form>
+</div>
+{{end}}
+{{template "foot" .}}
+
diff --git a/templates/patches.html b/templates/patches.html
new file mode 100644
index 0000000..a52040e
--- /dev/null
+++ b/templates/patches.html
@@ -0,0 +1,54 @@
+{{template "head" .}}
+{{define "title"}}patches - {{.Repo}} - fierj{{end}}
+{{template "repo-header" .}}
+{{template "repo-tabs" .}}
+
+<div class="threads-header">
+    <div class="threads-state-toggle">
+        <a href="/{{.Repo}}/patches?state=open" {{if eq .State "open" }} class="active" {{end}}>Open</a>
+        <a href="/{{.Repo}}/patches?state=merged" {{if eq .State "merged" }} class="active" {{end}}>Merged</a>
+        <a href="/{{.Repo}}/patches?state=closed" {{if eq .State "closed" }} class="active" {{end}}>Closed</a>
+    </div>
+    <a href="/{{.Repo}}/patches/new" class="btn btn-primary">New patch</a>
+</div>
+
+{{if .FreeBranches}}
+<div class="patch-branches-hint">
+    <strong>Branches available for merge:</strong>
+    {{range .FreeBranches}}<code>{{.}}</code> {{end}}
+</div>
+{{end}}
+
+{{if .Patches}}
+<div class="thread-list">
+    {{range .Patches}}
+    <div class="thread-list-item">
+        <svg class="icon thread-icon-{{.State}}" viewBox="0 0 16 16" fill="currentColor">
+            {{if eq .State "open"}}
+            <path
+                d="M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354Z" />
+            {{else if eq .State "merged"}}
+            <path
+                d="M5.45 5.154A4.25 4.25 0 0 0 9.25 7.5h1.378a2.251 2.251 0 1 1 0 1.5H9.25A5.734 5.734 0 0 1 5 7.123v3.505a2.25 2.25 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.95-.218Z" />
+            {{else}}
+            <path
+                d="M3.25 1A2.25 2.25 0 0 1 4 5.372v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 3.25 1Zm9.5 5.5a.75.75 0 0 1 .75.75v3.378a2.251 2.251 0 1 1-1.5 0V7.25a.75.75 0 0 1 .75-.75Zm-2.03-5.273a.75.75 0 0 1 1.06 0l2 2a.75.75 0 0 1-1.06 1.06L12 3.56v2.69a.75.75 0 0 1-1.5 0V3.56l-.72.72a.75.75 0 0 1-1.06-1.06Z" />
+            {{end}}
+        </svg>
+        <div class="thread-list-content">
+            <a href="/{{$.Repo}}/patches/{{.ID}}" class="thread-title">{{.Title}}</a>
+            <span class="thread-meta">
+                {{if .Branch}}branch: <code>{{.Branch}}</code> · {{end}}
+                {{.AuthorName}} · {{timeAgo .Updated}}
+            </span>
+        </div>
+    </div>
+    {{end}}
+</div>
+{{else}}
+<div class="empty-state">
+    <p>No {{.State}} patches.</p>
+</div>
+{{end}}
+{{template "foot" .}}
+