fierj
PublicTiny 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" .}}
+