diff --git a/khatru/grasp/handlers.go b/khatru/grasp/handlers.go
new file mode 100644
index 0000000..1521188
--- /dev/null
+++ b/khatru/grasp/handlers.go
@@ -0,0 +1,86 @@
+package grasp
+
+import (
+ "fmt"
+ "net/http"
+ "regexp"
+ "strings"
+
+ "fiatjaf.com/nostr"
+ "fiatjaf.com/nostr/nip19"
+)
+
+var asciiPattern = regexp.MustCompile(`^[\w-.]+$`)
+
+// handleGitRequest validates .git suffix and decodes npub, then calls the handler
+func (gs *GraspServer) handleGitRequest(
+ w http.ResponseWriter,
+ r *http.Request,
+ base http.Handler,
+ handler func(http.ResponseWriter,
+ *http.Request,
+ nostr.PubKey,
+ string,
+ ),
+) {
+ npub := r.PathValue("npub")
+ repoWithGit := r.PathValue("repo")
+
+ // validate .git suffix
+ if !strings.HasSuffix(repoWithGit, ".git") {
+ base.ServeHTTP(w, r)
+ return
+ }
+
+ repoName := strings.TrimSuffix(repoWithGit, ".git")
+
+ // validate repo name
+ if !asciiPattern.MatchString(repoName) {
+ http.Error(w, "invalid repository name", 400)
+ return
+ }
+
+ // decode npub to pubkey
+ _, value, err := nip19.Decode(npub)
+ if err != nil {
+ http.Error(w, "invalid npub", 400)
+ return
+ }
+ pk, ok := value.(nostr.PubKey)
+ if !ok {
+ http.Error(w, "invalid npub", 400)
+ return
+ }
+
+ handler(w, r, pk, repoName)
+}
+
+// serveRepoPage serves a webpage for the repository
+func (gs *GraspServer) serveRepoPage(w http.ResponseWriter, r *http.Request, npub, repoName string) {
+ w.Header().Set("Content-Type", "text/html")
+ html := fmt.Sprintf(`
+
+
+ %s/%s - NIP-34 Git Repository
+
+
+
+ Repository: %s/%s
+
+
This is a NIP-34 git repository served over Nostr.
+
+ Clone this repository
+ Use a git-nostr client to clone:
+ git clone %s/%s/%s.git
+ Browse
+ Use a git-nostr web client or Nostr client to browse this repository.
+
+`, npub, repoName, npub, repoName, r.Host, npub, repoName)
+ fmt.Fprint(w, html)
+}
diff --git a/khatru/grasp/helpers.go b/khatru/grasp/helpers.go
new file mode 100644
index 0000000..87efa19
--- /dev/null
+++ b/khatru/grasp/helpers.go
@@ -0,0 +1,175 @@
+package grasp
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "slices"
+ "strings"
+
+ "fiatjaf.com/nostr"
+ "fiatjaf.com/nostr/nip34"
+ "github.com/go-git/go-git/v5/plumbing/format/pktline"
+)
+
+const zeroRef = "0000000000000000000000000000000000000000"
+
+// validatePush checks if a push is authorized via NIP-34 repository state events
+func (gs *GraspServer) validatePush(
+ ctx context.Context,
+ pubkey nostr.PubKey,
+ repoName string,
+ bodyBytes []byte,
+) error {
+ // query for repository state events (kind 30618)
+ if gs.Relay.QueryStored == nil {
+ return errors.New("relay has no QueryStored function")
+ }
+
+ // check state
+ var state nip34.RepositoryState
+ for evt := range gs.Relay.QueryStored(ctx, nostr.Filter{
+ Kinds: []nostr.Kind{nostr.KindRepositoryState},
+ Authors: []nostr.PubKey{pubkey},
+ Tags: nostr.TagMap{"d": []string{repoName}},
+ Limit: 1,
+ }) {
+ state = nip34.ParseRepositoryState(evt)
+ }
+ if state.Event.ID == nostr.ZeroID {
+ return fmt.Errorf("no state found for repository '%s'", repoName)
+ }
+
+ // get repository announcement to check maintainers
+ var announcement nip34.Repository
+ for evt := range gs.Relay.QueryStored(ctx, nostr.Filter{
+ Kinds: []nostr.Kind{nostr.KindRepositoryAnnouncement},
+ Authors: []nostr.PubKey{pubkey},
+ Tags: nostr.TagMap{"d": []string{repoName}},
+ Limit: 1,
+ }) {
+ announcement = nip34.ParseRepository(evt)
+ }
+ if announcement.Event.ID == nostr.ZeroID {
+ return fmt.Errorf("no announcement found for repository '%s'", repoName)
+ }
+
+ // ensure pusher is authorized (owner or maintainer)
+ if pubkey != announcement.PubKey && !slices.Contains(announcement.Maintainers, pubkey) {
+ return fmt.Errorf("pusher '%s' is not authorized for repository '%s'", pubkey, repoName)
+ }
+
+ // parse pktline to extract and validate all push refs
+ pkt := pktline.NewScanner(bytes.NewReader(bodyBytes))
+ for pkt.Scan() {
+ if err := pkt.Err(); err != nil {
+ return fmt.Errorf("invalid pkt: %v", err)
+ }
+ line := string(pkt.Bytes())
+ if len(line) < 40 {
+ continue
+ }
+
+ spl := strings.Split(line, " ")
+ from := spl[0]
+ to := spl[1]
+ ref := strings.TrimRight(spl[2], "\x00")
+
+ // handle refs/nostr/ pushes
+ if strings.HasPrefix(ref, "refs/nostr/") {
+ // query for the event
+ eventId := ref[11:]
+ id, err := nostr.IDFromHex(eventId)
+ if err != nil {
+ return fmt.Errorf("push rejected: invalid event id %s", eventId)
+ }
+ var foundEvent bool
+ for evt := range gs.Relay.QueryStored(ctx, nostr.Filter{
+ IDs: []nostr.ID{id},
+ }) {
+ // check if event has a "c" tag matching the commit
+ hasMatchingCommit := false
+ for _, tag := range evt.Tags {
+ if tag[0] == "c" && len(tag) > 1 && tag[1] == to {
+ hasMatchingCommit = true
+ break
+ }
+ }
+ if !hasMatchingCommit {
+ return fmt.Errorf("push rejected: event %s has different tip (expected %s)", eventId, to)
+ }
+ foundEvent = true
+ break
+ }
+ if !foundEvent {
+ return fmt.Errorf("push rejected: event %s not found", eventId)
+ }
+ continue
+ }
+
+ // validate branch pushes
+ if strings.HasPrefix(ref, "refs/heads/") {
+ branchName := ref[11:]
+ // pushing a branch
+ if commitId, exists := state.Branches[branchName]; exists && to == commitId {
+ continue
+ }
+ // deleting a branch
+ if _, exists := state.Branches[branchName]; to == zeroRef && !exists {
+ continue
+ }
+ return fmt.Errorf("push unauthorized: ref %s %s->%s does not match state", ref, from, to)
+ }
+
+ // validate tag pushes
+ if strings.HasPrefix(ref, "refs/tags/") {
+ tagName := ref[10:]
+ // pushing a tag
+ if commitId, exists := state.Tags[tagName]; exists && to == commitId {
+ continue
+ }
+ // deleting a tag
+ if _, exists := state.Tags[tagName]; to == zeroRef && !exists {
+ continue
+ }
+ return fmt.Errorf("push unauthorized: ref %s %s->%s does not match state", ref, from, to)
+ }
+ }
+
+ return nil
+}
+
+// repoExists checks if a repository has an announcement event (kind 30617)
+func (gs *GraspServer) repoExists(pubkey nostr.PubKey, repoName string) bool {
+ for range gs.Relay.QueryStored(context.Background(), nostr.Filter{
+ Kinds: []nostr.Kind{nostr.KindRepositoryAnnouncement},
+ Authors: []nostr.PubKey{pubkey},
+ Tags: nostr.TagMap{"d": []string{repoName}},
+ }) {
+ return true
+ }
+ return false
+}
+
+// newWriteFlusher creates a write flusher for streaming responses
+func (gs *GraspServer) newWriteFlusher(w http.ResponseWriter) io.Writer {
+ return writeFlusher{w.(interface {
+ io.Writer
+ http.Flusher
+ })}
+}
+
+type writeFlusher struct {
+ wf interface {
+ io.Writer
+ http.Flusher
+ }
+}
+
+func (w writeFlusher) Write(p []byte) (int, error) {
+ defer w.wf.Flush()
+ return w.wf.Write(p)
+}
diff --git a/khatru/grasp/info.go b/khatru/grasp/info.go
new file mode 100644
index 0000000..9c8bb00
--- /dev/null
+++ b/khatru/grasp/info.go
@@ -0,0 +1,104 @@
+package grasp
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "syscall"
+
+ "fiatjaf.com/nostr"
+)
+
+// handleInfoRefs handles the git info/refs endpoint
+func (gs *GraspServer) handleInfoRefs(
+ w http.ResponseWriter,
+ r *http.Request,
+ pubkey nostr.PubKey,
+ repoName string,
+) {
+ if !gs.repoExists(pubkey, repoName) {
+ w.Header().Set("content-type", "text/plain; charset=UTF-8")
+ w.WriteHeader(404)
+ fmt.Fprintf(w, "repository announcement event not found during info-refs\n")
+ return
+ }
+
+ repoPath := filepath.Join(gs.RepositoryDir, repoName)
+ serviceName := r.URL.Query().Get("service")
+
+ w.Header().Set("Connection", "Keep-Alive")
+ w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate")
+ w.Header().Set("Content-Type", "application/x-"+serviceName+"-advertisement")
+
+ if _, err := os.Stat(repoPath); os.IsNotExist(err) {
+ // if the repo doesn't exist that's because it wasn't pushed yet, so return an empty response
+
+ // service advertisement header: packet-line with "# service=\n"
+ serviceLine := fmt.Sprintf("# service=%s\n", serviceName)
+ // write packet line
+ length := len(serviceLine) + 4
+ fmt.Fprintf(w, "%04x%s", length, serviceLine)
+
+ // flush
+ w.Write([]byte("0000"))
+
+ // another flush packet to indicate end of refs
+ w.Write([]byte("0000"))
+
+ return
+ }
+
+ if err := gs.runInfoRefs(w, r, serviceName, repoPath); err != nil {
+ gs.Log("error on info-refs rpc: %s\n", err)
+ return
+ }
+}
+
+// runInfoRefs executes git-upload-pack with --http-backend-info-refs
+func (gs *GraspServer) runInfoRefs(w http.ResponseWriter, r *http.Request, serviceName, repoPath string) error {
+ cmd := exec.Command(serviceName, "--stateless-rpc", "--http-backend-info-refs", ".")
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+ cmd.Dir = repoPath
+ cmd.Env = append(os.Environ(), fmt.Sprintf("GIT_PROTOCOL=%s", r.Header.Get("Git-Protocol")))
+
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+
+ stdoutPipe, err := cmd.StdoutPipe()
+ if err != nil {
+ return fmt.Errorf("failed to create stdout pipe: %w", err)
+ }
+
+ // write pack line header only if not git protocol v2
+ if !strings.Contains(r.Header.Get("Git-Protocol"), "version=2") {
+ // packLine
+ s := "# service=" + serviceName + "\n"
+ if _, err := fmt.Fprintf(w, "%04x%s", len(s)+4, s); err != nil {
+ return fmt.Errorf("failed to write pack line: %w", err)
+ }
+
+ // packFlush
+ if _, err := fmt.Fprint(w, "0000"); err != nil {
+ return fmt.Errorf("failed to flush pack: %w", err)
+ }
+ }
+
+ if err := cmd.Start(); err != nil {
+ return fmt.Errorf("failed to start %s: %w, %s", serviceName, err, stderr.String())
+ }
+
+ io.Copy(gs.newWriteFlusher(w), stdoutPipe)
+ stdoutPipe.Close()
+
+ if err := cmd.Wait(); err != nil {
+ gs.Log("%s failed: %w, stderr: %s", serviceName, err, stderr.String())
+ return fmt.Errorf("%s failed: %w, stderr: %s", serviceName, err, stderr.String())
+ }
+
+ return nil
+}
diff --git a/khatru/grasp/read.go b/khatru/grasp/read.go
new file mode 100644
index 0000000..2658fac
--- /dev/null
+++ b/khatru/grasp/read.go
@@ -0,0 +1,117 @@
+package grasp
+
+import (
+ "bytes"
+ "compress/gzip"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "syscall"
+
+ "fiatjaf.com/nostr"
+)
+
+func (gs *GraspServer) handleGitUploadPack(
+ w http.ResponseWriter,
+ r *http.Request,
+ pubkey nostr.PubKey,
+ repoName string,
+) {
+ repoPath := filepath.Join(gs.RepositoryDir, repoName)
+
+ // for upload-pack (pull), check if repository exists
+ if !gs.repoExists(pubkey, repoName) {
+ w.Header().Set("content-type", "text/plain; charset=UTF-8")
+ w.WriteHeader(404)
+ fmt.Fprintf(w, "repository announcement event not found during upload-pack\n")
+ return
+ }
+
+ if gs.OnRead != nil {
+ reject, msg := gs.OnRead(r.Context(), pubkey, repoName)
+ if reject {
+ w.Header().Set("content-type", "text/plain; charset=UTF-8")
+ w.WriteHeader(403)
+ fmt.Fprintf(w, "%s\n", msg)
+ return
+ }
+ }
+
+ const expectedContentType = "application/x-git-upload-pack-request"
+ contentType := r.Header.Get("Content-Type")
+ if contentType != expectedContentType {
+ w.Header().Set("content-type", "text/plain; charset=UTF-8")
+ w.WriteHeader(415)
+ fmt.Fprintf(w, "expected Content-Type: '%s', but received '%s'\n", expectedContentType, contentType)
+ return
+ }
+
+ var bodyReader io.ReadCloser = r.Body
+ if r.Header.Get("Content-Encoding") == "gzip" {
+ gzipReader, err := gzip.NewReader(r.Body)
+ if err != nil {
+ w.Header().Set("content-type", "text/plain; charset=UTF-8")
+ w.WriteHeader(500)
+ fmt.Fprintf(w, "failed to create gzip reader, handler: UploadPack, error: %v\n", err)
+ return
+ }
+ defer gzipReader.Close()
+ bodyReader = gzipReader
+ }
+
+ w.Header().Set("Content-Type", "application/x-git-upload-pack-result")
+ w.Header().Set("Connection", "Keep-Alive")
+ w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate")
+ w.WriteHeader(200)
+
+ if err := gs.runUploadPack(w, r, repoPath, bodyReader); err != nil {
+ w.Header().Set("content-type", "text/plain; charset=UTF-8")
+ w.WriteHeader(403)
+ fmt.Fprintf(w, "failed to execute git-upload-pack, handler: UploadPack, error: %v\n", err)
+ return
+ }
+}
+
+// runUploadPack executes git-upload-pack for pull operations
+func (gs *GraspServer) runUploadPack(w http.ResponseWriter, r *http.Request, repoPath string, bodyReader io.ReadCloser) error {
+ cmd := exec.Command("git", "upload-pack", "--stateless-rpc", ".")
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+ cmd.Dir = repoPath
+ cmd.Env = append(os.Environ(), fmt.Sprintf("GIT_PROTOCOL=%s", r.Header.Get("Git-Protocol")))
+
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+
+ stdoutPipe, err := cmd.StdoutPipe()
+ if err != nil {
+ return fmt.Errorf("failed to create stdout pipe: %w", err)
+ }
+
+ stdinPipe, err := cmd.StdinPipe()
+ if err != nil {
+ return fmt.Errorf("failed to create stdin pipe: %w", err)
+ }
+
+ if err := cmd.Start(); err != nil {
+ return fmt.Errorf("failed to start git-upload-pack: %w", err)
+ }
+
+ // copy input to stdin
+ go func() {
+ defer stdinPipe.Close()
+ io.Copy(stdinPipe, bodyReader)
+ }()
+
+ // copy output to response
+ io.Copy(gs.newWriteFlusher(w), stdoutPipe)
+ stdoutPipe.Close()
+
+ if err := cmd.Wait(); err != nil {
+ return fmt.Errorf("git-upload-pack failed: %w, stderr: %s", err, stderr.String())
+ }
+
+ return nil
+}
diff --git a/khatru/grasp/server.go b/khatru/grasp/server.go
index 100c35c..d125373 100644
--- a/khatru/grasp/server.go
+++ b/khatru/grasp/server.go
@@ -1,34 +1,15 @@
package grasp
import (
- "bytes"
- "compress/gzip"
"context"
- "errors"
"fmt"
- "io"
"net/http"
"os"
- "os/exec"
- "path/filepath"
- "regexp"
- "slices"
- "strings"
- "sync"
- "syscall"
- "time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/khatru"
- "fiatjaf.com/nostr/nip19"
- "fiatjaf.com/nostr/nip34"
- "github.com/go-git/go-git/v5/plumbing/format/pktline"
)
-const zeroRef = "0000000000000000000000000000000000000000"
-
-var asciiPattern = regexp.MustCompile(`^[\w-.]+$`)
-
type GraspServer struct {
ServiceURL string
RepositoryDir string
@@ -92,710 +73,3 @@ func New(rl *khatru.Relay, repositoryDir string) *GraspServer {
return gs
}
-
-// handleGitRequest validates .git suffix and decodes npub, then calls the handler
-func (gs *GraspServer) handleGitRequest(
- w http.ResponseWriter,
- r *http.Request,
- base http.Handler,
- handler func(http.ResponseWriter,
- *http.Request,
- nostr.PubKey,
- string,
- ),
-) {
- npub := r.PathValue("npub")
- repoWithGit := r.PathValue("repo")
-
- // validate .git suffix
- if !strings.HasSuffix(repoWithGit, ".git") {
- base.ServeHTTP(w, r)
- return
- }
-
- repoName := strings.TrimSuffix(repoWithGit, ".git")
-
- // validate repo name
- if !asciiPattern.MatchString(repoName) {
- http.Error(w, "invalid repository name", 400)
- return
- }
-
- // decode npub to pubkey
- _, value, err := nip19.Decode(npub)
- if err != nil {
- http.Error(w, "invalid npub", 400)
- return
- }
- pk, ok := value.(nostr.PubKey)
- if !ok {
- http.Error(w, "invalid npub", 400)
- return
- }
-
- handler(w, r, pk, repoName)
-}
-
-// handleInfoRefs handles the git info/refs endpoint
-func (gs *GraspServer) handleInfoRefs(
- w http.ResponseWriter,
- r *http.Request,
- pubkey nostr.PubKey,
- repoName string,
-) {
- if !gs.repoExists(pubkey, repoName) {
- w.Header().Set("content-type", "text/plain; charset=UTF-8")
- w.WriteHeader(404)
- fmt.Fprintf(w, "repository announcement event not found during info-refs\n")
- return
- }
-
- repoPath := filepath.Join(gs.RepositoryDir, repoName)
- serviceName := r.URL.Query().Get("service")
-
- w.Header().Set("Connection", "Keep-Alive")
- w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate")
- w.Header().Set("Content-Type", "application/x-"+serviceName+"-advertisement")
-
- if _, err := os.Stat(repoPath); os.IsNotExist(err) {
- // if the repo doesn't exist that's because it wasn't pushed yet, so return an empty response
-
- // service advertisement header: packet-line with "# service=\n"
- serviceLine := fmt.Sprintf("# service=%s\n", serviceName)
- // write packet line
- length := len(serviceLine) + 4
- fmt.Fprintf(w, "%04x%s", length, serviceLine)
-
- // flush
- w.Write([]byte("0000"))
-
- // another flush packet to indicate end of refs
- w.Write([]byte("0000"))
-
- return
- }
-
- if err := gs.runInfoRefs(w, r, serviceName, repoPath); err != nil {
- gs.Log("error on info-refs rpc: %s\n", err)
- return
- }
-}
-
-func (gs *GraspServer) handleGitUploadPack(
- w http.ResponseWriter,
- r *http.Request,
- pubkey nostr.PubKey,
- repoName string,
-) {
- repoPath := filepath.Join(gs.RepositoryDir, repoName)
-
- // for upload-pack (pull), check if repository exists
- if !gs.repoExists(pubkey, repoName) {
- w.Header().Set("content-type", "text/plain; charset=UTF-8")
- w.WriteHeader(404)
- fmt.Fprintf(w, "repository announcement event not found during upload-pack\n")
- return
- }
-
- if gs.OnRead != nil {
- reject, msg := gs.OnRead(r.Context(), pubkey, repoName)
- if reject {
- w.Header().Set("content-type", "text/plain; charset=UTF-8")
- w.WriteHeader(403)
- fmt.Fprintf(w, "%s\n", msg)
- return
- }
- }
-
- const expectedContentType = "application/x-git-upload-pack-request"
- contentType := r.Header.Get("Content-Type")
- if contentType != expectedContentType {
- w.Header().Set("content-type", "text/plain; charset=UTF-8")
- w.WriteHeader(415)
- fmt.Fprintf(w, "expected Content-Type: '%s', but received '%s'\n", expectedContentType, contentType)
- return
- }
-
- var bodyReader io.ReadCloser = r.Body
- if r.Header.Get("Content-Encoding") == "gzip" {
- gzipReader, err := gzip.NewReader(r.Body)
- if err != nil {
- w.Header().Set("content-type", "text/plain; charset=UTF-8")
- w.WriteHeader(500)
- fmt.Fprintf(w, "failed to create gzip reader, handler: UploadPack, error: %v\n", err)
- return
- }
- defer gzipReader.Close()
- bodyReader = gzipReader
- }
-
- w.Header().Set("Content-Type", "application/x-git-upload-pack-result")
- w.Header().Set("Connection", "Keep-Alive")
- w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate")
- w.WriteHeader(200)
-
- if err := gs.runUploadPack(w, r, repoPath, bodyReader); err != nil {
- w.Header().Set("content-type", "text/plain; charset=UTF-8")
- w.WriteHeader(403)
- fmt.Fprintf(w, "failed to execute git-upload-pack, handler: UploadPack, error: %v\n", err)
- return
- }
-}
-
-func (gs *GraspServer) handleGitReceivePack(
- w http.ResponseWriter,
- r *http.Request,
- pubkey nostr.PubKey,
- repoName string,
-) {
- // for receive-pack (push), validate authorization via NIP-34 events
- body := &bytes.Buffer{}
- io.Copy(body, r.Body)
-
- if err := gs.validatePush(r.Context(), pubkey, repoName, body.Bytes()); err != nil {
- w.Header().Set("content-type", "text/plain; charset=UTF-8")
- w.WriteHeader(403)
- fmt.Fprintf(w, "unauthorized push: %v\n", err)
- return
- }
-
- if gs.OnWrite != nil {
- reject, msg := gs.OnWrite(r.Context(), pubkey, repoName)
- if reject {
- w.Header().Set("content-type", "text/plain; charset=UTF-8")
- w.WriteHeader(403)
- fmt.Fprintf(w, "%s\n", msg)
- return
- }
- }
-
- repoPath := filepath.Join(gs.RepositoryDir, repoName)
-
- // initialize git repo if it doesn't exist
- if _, err := os.Stat(repoPath); os.IsNotExist(err) {
- if err := os.MkdirAll(repoPath, 0755); err != nil {
- w.Header().Set("content-type", "text/plain; charset=UTF-8")
- w.WriteHeader(500)
- fmt.Fprintf(w, "failed to create repository: %s\n", err)
- return
- }
-
- cmd := exec.Command("git", "init", "--bare")
- cmd.Dir = repoPath
- if output, err := cmd.CombinedOutput(); err != nil {
- w.Header().Set("content-type", "text/plain; charset=UTF-8")
- w.WriteHeader(500)
- fmt.Fprintf(w, "failed to initialize repository: %s, output: %s\n", err, string(output))
- return
- }
-
- // disable denyNonFastForwards and denyCurrentBranch to allow force pushes
- for _, config := range []struct {
- key string
- value string
- }{
- {"receive.denyNonFastForwards", "false"},
- {"receive.denyCurrentBranch", "updateInstead"},
- {"uploadpack.allowReachableSHA1InWant", "true"},
- {"uploadpack.allowTipSHA1InWant", "true"},
- } {
- cmd = exec.Command("git", "config", config.key, config.value)
- cmd.Dir = repoPath
- if output, err := cmd.CombinedOutput(); err != nil {
- w.Header().Set("content-type", "text/plain; charset=UTF-8")
- w.WriteHeader(500)
- fmt.Fprintf(w, "failed to configure repository with %s=%s: %s, output: %s\n",
- config.key, config.value, err, string(output))
- return
- }
- }
- }
-
- w.Header().Set("Content-Type", "application/x-git-receive-pack-result")
- w.Header().Set("Connection", "Keep-Alive")
- w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate")
-
- if err := gs.runReceivePack(w, r, repoPath, io.NopCloser(bytes.NewReader(body.Bytes()))); err != nil {
- w.Header().Set("content-type", "text/plain; charset=UTF-8")
- w.WriteHeader(403)
- fmt.Fprintf(w, "runReceivePack: %v\n", err)
- return
- }
-
- // update HEAD per state announcement
- if err := gs.updateHEAD(r.Context(), pubkey, repoName, repoPath); err != nil {
- w.Header().Set("content-type", "text/plain; charset=UTF-8")
- w.WriteHeader(403)
- fmt.Fprintf(w, "failed to update HEAD: %v\n", err)
- return
- }
-
- // cleanup merged patches
- go gs.cleanupMergedPatches(r.Context(), pubkey, repoName, repoPath)
-}
-
-// validatePush checks if a push is authorized via NIP-34 repository state events
-func (gs *GraspServer) validatePush(
- ctx context.Context,
- pubkey nostr.PubKey,
- repoName string,
- bodyBytes []byte,
-) error {
- // query for repository state events (kind 30618)
- if gs.Relay.QueryStored == nil {
- return errors.New("relay has no QueryStored function")
- }
-
- // check state
- var state nip34.RepositoryState
- for evt := range gs.Relay.QueryStored(ctx, nostr.Filter{
- Kinds: []nostr.Kind{nostr.KindRepositoryState},
- Authors: []nostr.PubKey{pubkey},
- Tags: nostr.TagMap{"d": []string{repoName}},
- Limit: 1,
- }) {
- state = nip34.ParseRepositoryState(evt)
- }
- if state.Event.ID == nostr.ZeroID {
- return fmt.Errorf("no state found for repository '%s'", repoName)
- }
-
- // get repository announcement to check maintainers
- var announcement nip34.Repository
- for evt := range gs.Relay.QueryStored(ctx, nostr.Filter{
- Kinds: []nostr.Kind{nostr.KindRepositoryAnnouncement},
- Authors: []nostr.PubKey{pubkey},
- Tags: nostr.TagMap{"d": []string{repoName}},
- Limit: 1,
- }) {
- announcement = nip34.ParseRepository(evt)
- }
- if announcement.Event.ID == nostr.ZeroID {
- return fmt.Errorf("no announcement found for repository '%s'", repoName)
- }
-
- // ensure pusher is authorized (owner or maintainer)
- if pubkey != announcement.PubKey && !slices.Contains(announcement.Maintainers, pubkey) {
- return fmt.Errorf("pusher '%s' is not authorized for repository '%s'", pubkey, repoName)
- }
-
- // parse pktline to extract and validate all push refs
- pkt := pktline.NewScanner(bytes.NewReader(bodyBytes))
- for pkt.Scan() {
- if err := pkt.Err(); err != nil {
- return fmt.Errorf("invalid pkt: %v", err)
- }
- line := string(pkt.Bytes())
- if len(line) < 40 {
- continue
- }
-
- spl := strings.Split(line, " ")
- from := spl[0]
- to := spl[1]
- ref := strings.TrimRight(spl[2], "\x00")
-
- // handle refs/nostr/ pushes
- if strings.HasPrefix(ref, "refs/nostr/") {
- // query for the event
- eventId := ref[11:]
- id, err := nostr.IDFromHex(eventId)
- if err != nil {
- return fmt.Errorf("push rejected: invalid event id %s", eventId)
- }
- var foundEvent bool
- for evt := range gs.Relay.QueryStored(ctx, nostr.Filter{
- IDs: []nostr.ID{id},
- }) {
- // check if event has a "c" tag matching the commit
- hasMatchingCommit := false
- for _, tag := range evt.Tags {
- if tag[0] == "c" && len(tag) > 1 && tag[1] == to {
- hasMatchingCommit = true
- break
- }
- }
- if !hasMatchingCommit {
- return fmt.Errorf("push rejected: event %s has different tip (expected %s)", eventId, to)
- }
- foundEvent = true
- break
- }
- if !foundEvent {
- return fmt.Errorf("push rejected: event %s not found", eventId)
- }
- continue
- }
-
- // validate branch pushes
- if strings.HasPrefix(ref, "refs/heads/") {
- branchName := ref[11:]
- // pushing a branch
- if commitId, exists := state.Branches[branchName]; exists && to == commitId {
- continue
- }
- // deleting a branch
- if _, exists := state.Branches[branchName]; to == zeroRef && !exists {
- continue
- }
- return fmt.Errorf("push unauthorized: ref %s %s->%s does not match state", ref, from, to)
- }
-
- // validate tag pushes
- if strings.HasPrefix(ref, "refs/tags/") {
- tagName := ref[10:]
- // pushing a tag
- if commitId, exists := state.Tags[tagName]; exists && to == commitId {
- continue
- }
- // deleting a tag
- if _, exists := state.Tags[tagName]; to == zeroRef && !exists {
- continue
- }
- return fmt.Errorf("push unauthorized: ref %s %s->%s does not match state", ref, from, to)
- }
- }
-
- return nil
-}
-
-// repoExists checks if a repository has an announcement event (kind 30617)
-func (gs *GraspServer) repoExists(pubkey nostr.PubKey, repoName string) bool {
- for range gs.Relay.QueryStored(context.Background(), nostr.Filter{
- Kinds: []nostr.Kind{nostr.KindRepositoryAnnouncement},
- Authors: []nostr.PubKey{pubkey},
- Tags: nostr.TagMap{"d": []string{repoName}},
- }) {
- return true
- }
- return false
-}
-
-// runInfoRefs executes git-upload-pack with --http-backend-info-refs
-func (gs *GraspServer) runInfoRefs(w http.ResponseWriter, r *http.Request, serviceName, repoPath string) error {
- cmd := exec.Command(serviceName, "--stateless-rpc", "--http-backend-info-refs", ".")
- cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
- cmd.Dir = repoPath
- cmd.Env = append(os.Environ(), fmt.Sprintf("GIT_PROTOCOL=%s", r.Header.Get("Git-Protocol")))
-
- var stderr bytes.Buffer
- cmd.Stderr = &stderr
-
- stdoutPipe, err := cmd.StdoutPipe()
- if err != nil {
- return fmt.Errorf("failed to create stdout pipe: %w", err)
- }
-
- // write pack line header only if not git protocol v2
- if !strings.Contains(r.Header.Get("Git-Protocol"), "version=2") {
- // packLine
- s := "# service=" + serviceName + "\n"
- if _, err := fmt.Fprintf(w, "%04x%s", len(s)+4, s); err != nil {
- return fmt.Errorf("failed to write pack line: %w", err)
- }
-
- // packFlush
- if _, err := fmt.Fprint(w, "0000"); err != nil {
- return fmt.Errorf("failed to flush pack: %w", err)
- }
- }
-
- if err := cmd.Start(); err != nil {
- return fmt.Errorf("failed to start %s: %w, %s", serviceName, err, stderr.String())
- }
-
- io.Copy(gs.newWriteFlusher(w), stdoutPipe)
- stdoutPipe.Close()
-
- if err := cmd.Wait(); err != nil {
- gs.Log("%s failed: %w, stderr: %s", serviceName, err, stderr.String())
- return fmt.Errorf("%s failed: %w, stderr: %s", serviceName, err, stderr.String())
- }
-
- return nil
-}
-
-// runUploadPack executes git-upload-pack for pull operations
-func (gs *GraspServer) runUploadPack(w http.ResponseWriter, r *http.Request, repoPath string, bodyReader io.ReadCloser) error {
- cmd := exec.Command("git", "upload-pack", "--stateless-rpc", ".")
- cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
- cmd.Dir = repoPath
- cmd.Env = append(os.Environ(), fmt.Sprintf("GIT_PROTOCOL=%s", r.Header.Get("Git-Protocol")))
-
- var stderr bytes.Buffer
- cmd.Stderr = &stderr
-
- stdoutPipe, err := cmd.StdoutPipe()
- if err != nil {
- return fmt.Errorf("failed to create stdout pipe: %w", err)
- }
-
- stdinPipe, err := cmd.StdinPipe()
- if err != nil {
- return fmt.Errorf("failed to create stdin pipe: %w", err)
- }
-
- if err := cmd.Start(); err != nil {
- return fmt.Errorf("failed to start git-upload-pack: %w", err)
- }
-
- // copy input to stdin
- go func() {
- defer stdinPipe.Close()
- io.Copy(stdinPipe, bodyReader)
- }()
-
- // copy output to response
- io.Copy(gs.newWriteFlusher(w), stdoutPipe)
- stdoutPipe.Close()
-
- if err := cmd.Wait(); err != nil {
- return fmt.Errorf("git-upload-pack failed: %w, stderr: %s", err, stderr.String())
- }
-
- return nil
-}
-
-// runReceivePack executes git-receive-pack for push operations
-func (gs *GraspServer) runReceivePack(w http.ResponseWriter, r *http.Request, repoPath string, bodyReader io.ReadCloser) error {
- cmd := exec.Command("git", "receive-pack", "--stateless-rpc", ".")
- cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
- cmd.Dir = repoPath
- cmd.Env = append(os.Environ(), fmt.Sprintf("GIT_PROTOCOL=%s", r.Header.Get("Git-Protocol")))
-
- var stderr bytes.Buffer
- cmd.Stderr = &stderr
-
- stdoutPipe, err := cmd.StdoutPipe()
- if err != nil {
- return fmt.Errorf("failed to create stdout pipe: %w", err)
- }
-
- stdinPipe, err := cmd.StdinPipe()
- if err != nil {
- return fmt.Errorf("failed to create stdin pipe: %w", err)
- }
-
- if err := cmd.Start(); err != nil {
- return fmt.Errorf("failed to start git-receive-pack: %w", err)
- }
-
- wg := sync.WaitGroup{}
-
- // copy input to stdin
- wg.Go(func() {
- defer stdinPipe.Close()
- if _, err := io.Copy(stdinPipe, bodyReader); err != nil {
- gs.Log("failed to copy to stdin pipe: %s", err)
- }
- })
-
- // copy output to response
- wg.Go(func() {
- defer stdoutPipe.Close()
- if _, err := io.Copy(gs.newWriteFlusher(w), stdoutPipe); err != nil {
- gs.Log("failed to copy to write flusher: %s", err)
- }
- })
-
- wg.Wait()
-
- if err := cmd.Wait(); err != nil {
- return fmt.Errorf("git-receive-pack failed: %w, stderr: %s", err, stderr.String())
- }
-
- return nil
-}
-
-// updateHEAD updates the repository HEAD based on the latest state announcement
-func (gs *GraspServer) updateHEAD(ctx context.Context, pubkey nostr.PubKey, repoName, repoPath string) error {
- if gs.Relay.QueryStored == nil {
- return fmt.Errorf("no QueryStored function")
- }
-
- // query for the latest state event
- var latestState *nip34.RepositoryState
- for evt := range gs.Relay.QueryStored(ctx, nostr.Filter{
- Kinds: []nostr.Kind{nostr.KindRepositoryState},
- Authors: []nostr.PubKey{pubkey},
- Tags: nostr.TagMap{"d": []string{repoName}},
- Limit: 1,
- }) {
- state := nip34.ParseRepositoryState(evt)
- latestState = &state
- break
- }
-
- if latestState == nil || latestState.HEAD == "" {
- // no state or no HEAD specified
- return nil
- }
-
- // verify the HEAD branch exists in the state
- if _, exists := latestState.Branches[latestState.HEAD]; !exists {
- return fmt.Errorf("HEAD branch %s not found in state", latestState.HEAD)
- }
-
- // update HEAD using git symbolic-ref
- cmd := exec.Command("git", "symbolic-ref", "HEAD", "refs/heads/"+latestState.HEAD)
- cmd.Dir = repoPath
- if output, err := cmd.CombinedOutput(); err != nil {
- return fmt.Errorf("failed to update HEAD: %w, output: %s", err, string(output))
- }
- return nil
-}
-
-// cleanupMergedPatches removes refs/nostr/ refs that have been merged into branches
-func (gs *GraspServer) cleanupMergedPatches(ctx context.Context, pubkey nostr.PubKey, repoName, repoPath string) {
- // use background context since request context will be cancelled
- ctx = context.Background()
-
- // wait 20 minutes before cleanup to allow events to propagate
- time.Sleep(20 * time.Minute)
-
- if gs.Relay.QueryStored == nil {
- return
- }
-
- // get current state to know which branches exist
- var state *nip34.RepositoryState
- for evt := range gs.Relay.QueryStored(ctx, nostr.Filter{
- Kinds: []nostr.Kind{nostr.KindRepositoryState},
- Authors: []nostr.PubKey{pubkey},
- Tags: nostr.TagMap{"d": []string{repoName}},
- Limit: 1,
- }) {
- parsed := nip34.ParseRepositoryState(evt)
- state = &parsed
- break
- }
-
- if state == nil {
- return
- }
-
- // list all refs/nostr/* refs
- cmd := exec.Command("git", "for-each-ref", "--format=%(refname)", "refs/nostr")
- cmd.Dir = repoPath
- output, err := cmd.Output()
- if err != nil {
- // no refs/nostr refs, nothing to clean up
- return
- }
-
- refs := strings.Split(strings.TrimSpace(string(output)), "\n")
- for _, ref := range refs {
- if ref == "" {
- continue
- }
-
- eventId := strings.TrimPrefix(ref, "refs/nostr/")
- id, err := nostr.IDFromHex(eventId)
- if err != nil {
- return
- }
-
- // check if there's still a valid patch event with a "c" tag referencing this commit
- hasValidEvent := false
- for evt := range gs.Relay.QueryStored(ctx, nostr.Filter{
- IDs: []nostr.ID{id},
- }) {
- // check if event has a "c" tag
- for _, tag := range evt.Tags {
- if tag[0] == "c" && len(tag) > 1 {
- hasValidEvent = true
- break
- }
- }
- break
- }
-
- if !hasValidEvent {
- // no valid event, delete the ref
- cmd := exec.Command("git", "update-ref", "-d", ref)
- cmd.Dir = repoPath
- if err := cmd.Run(); err != nil {
- gs.Log("failed to delete ref %s: %s\n", ref, err)
- } else {
- gs.Log("deleted ref %s (no corresponding event)\n", ref)
- }
- continue
- }
-
- // check if the commit is merged into any branch
- for branchName, commitId := range state.Branches {
- // get the commit ID for this ref
- cmd := exec.Command("git", "rev-parse", ref)
- cmd.Dir = repoPath
- refCommit, err := cmd.Output()
- if err != nil {
- continue
- }
-
- // check if ref commit is ancestor of branch head
- cmd = exec.Command("git", "merge-base", "--is-ancestor", strings.TrimSpace(string(refCommit)), commitId)
- cmd.Dir = repoPath
- if err := cmd.Run(); err == nil {
- // it's merged! delete the ref
- cmd := exec.Command("git", "update-ref", "-d", ref)
- cmd.Dir = repoPath
- if err := cmd.Run(); err != nil {
- gs.Log("failed to delete ref %s: %s\n", ref, err)
- } else {
- gs.Log("deleted ref %s (merged into %s)\n", ref, branchName)
- }
- break
- }
- }
- }
-}
-
-// serveRepoPage serves a webpage for the repository
-func (gs *GraspServer) serveRepoPage(w http.ResponseWriter, r *http.Request, npub, repoName string) {
- w.Header().Set("Content-Type", "text/html")
- html := fmt.Sprintf(`
-
-
- %s/%s - NIP-34 Git Repository
-
-
-
- Repository: %s/%s
-
-
This is a NIP-34 git repository served over Nostr.
-
- Clone this repository
- Use a git-nostr client to clone:
- git clone %s/%s/%s.git
- Browse
- Use a git-nostr web client or Nostr client to browse this repository.
-
-`, npub, repoName, npub, repoName, r.Host, npub, repoName)
- fmt.Fprint(w, html)
-}
-
-// newWriteFlusher creates a write flusher for streaming responses
-func (gs *GraspServer) newWriteFlusher(w http.ResponseWriter) io.Writer {
- return writeFlusher{w.(interface {
- io.Writer
- http.Flusher
- })}
-}
-
-type writeFlusher struct {
- wf interface {
- io.Writer
- http.Flusher
- }
-}
-
-func (w writeFlusher) Write(p []byte) (int, error) {
- defer w.wf.Flush()
- return w.wf.Write(p)
-}
diff --git a/khatru/grasp/write.go b/khatru/grasp/write.go
new file mode 100644
index 0000000..c1a0297
--- /dev/null
+++ b/khatru/grasp/write.go
@@ -0,0 +1,305 @@
+package grasp
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "sync"
+ "syscall"
+ "time"
+
+ "fiatjaf.com/nostr"
+ "fiatjaf.com/nostr/nip34"
+)
+
+func (gs *GraspServer) handleGitReceivePack(
+ w http.ResponseWriter,
+ r *http.Request,
+ pubkey nostr.PubKey,
+ repoName string,
+) {
+ // for receive-pack (push), validate authorization via NIP-34 events
+ body := &bytes.Buffer{}
+ io.Copy(body, r.Body)
+
+ if err := gs.validatePush(r.Context(), pubkey, repoName, body.Bytes()); err != nil {
+ w.Header().Set("content-type", "text/plain; charset=UTF-8")
+ w.WriteHeader(403)
+ fmt.Fprintf(w, "unauthorized push: %v\n", err)
+ return
+ }
+
+ if gs.OnWrite != nil {
+ reject, msg := gs.OnWrite(r.Context(), pubkey, repoName)
+ if reject {
+ w.Header().Set("content-type", "text/plain; charset=UTF-8")
+ w.WriteHeader(403)
+ fmt.Fprintf(w, "%s\n", msg)
+ return
+ }
+ }
+
+ repoPath := filepath.Join(gs.RepositoryDir, repoName)
+
+ // initialize git repo if it doesn't exist
+ if _, err := os.Stat(repoPath); os.IsNotExist(err) {
+ if err := os.MkdirAll(repoPath, 0755); err != nil {
+ w.Header().Set("content-type", "text/plain; charset=UTF-8")
+ w.WriteHeader(500)
+ fmt.Fprintf(w, "failed to create repository: %s\n", err)
+ return
+ }
+
+ cmd := exec.Command("git", "init", "--bare")
+ cmd.Dir = repoPath
+ if output, err := cmd.CombinedOutput(); err != nil {
+ w.Header().Set("content-type", "text/plain; charset=UTF-8")
+ w.WriteHeader(500)
+ fmt.Fprintf(w, "failed to initialize repository: %s, output: %s\n", err, string(output))
+ return
+ }
+
+ // disable denyNonFastForwards and denyCurrentBranch to allow force pushes
+ for _, config := range []struct {
+ key string
+ value string
+ }{
+ {"receive.denyNonFastForwards", "false"},
+ {"receive.denyCurrentBranch", "updateInstead"},
+ {"uploadpack.allowReachableSHA1InWant", "true"},
+ {"uploadpack.allowTipSHA1InWant", "true"},
+ } {
+ cmd = exec.Command("git", "config", config.key, config.value)
+ cmd.Dir = repoPath
+ if output, err := cmd.CombinedOutput(); err != nil {
+ w.Header().Set("content-type", "text/plain; charset=UTF-8")
+ w.WriteHeader(500)
+ fmt.Fprintf(w, "failed to configure repository with %s=%s: %s, output: %s\n",
+ config.key, config.value, err, string(output))
+ return
+ }
+ }
+ }
+
+ w.Header().Set("Content-Type", "application/x-git-receive-pack-result")
+ w.Header().Set("Connection", "Keep-Alive")
+ w.Header().Set("Cache-Control", "no-cache, max-age=0, must-revalidate")
+
+ if err := gs.runReceivePack(w, r, repoPath, io.NopCloser(bytes.NewReader(body.Bytes()))); err != nil {
+ w.Header().Set("content-type", "text/plain; charset=UTF-8")
+ w.WriteHeader(403)
+ fmt.Fprintf(w, "runReceivePack: %v\n", err)
+ return
+ }
+
+ // update HEAD per state announcement
+ if err := gs.updateHEAD(r.Context(), pubkey, repoName, repoPath); err != nil {
+ w.Header().Set("content-type", "text/plain; charset=UTF-8")
+ w.WriteHeader(403)
+ fmt.Fprintf(w, "failed to update HEAD: %v\n", err)
+ return
+ }
+
+ // cleanup merged patches
+ go gs.cleanupMergedPatches(r.Context(), pubkey, repoName, repoPath)
+}
+
+// runReceivePack executes git-receive-pack for push operations
+func (gs *GraspServer) runReceivePack(w http.ResponseWriter, r *http.Request, repoPath string, bodyReader io.ReadCloser) error {
+ cmd := exec.Command("git", "receive-pack", "--stateless-rpc", ".")
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+ cmd.Dir = repoPath
+ cmd.Env = append(os.Environ(), fmt.Sprintf("GIT_PROTOCOL=%s", r.Header.Get("Git-Protocol")))
+
+ var stderr bytes.Buffer
+ cmd.Stderr = &stderr
+
+ stdoutPipe, err := cmd.StdoutPipe()
+ if err != nil {
+ return fmt.Errorf("failed to create stdout pipe: %w", err)
+ }
+
+ stdinPipe, err := cmd.StdinPipe()
+ if err != nil {
+ return fmt.Errorf("failed to create stdin pipe: %w", err)
+ }
+
+ if err := cmd.Start(); err != nil {
+ return fmt.Errorf("failed to start git-receive-pack: %w", err)
+ }
+
+ wg := sync.WaitGroup{}
+
+ // copy input to stdin
+ wg.Go(func() {
+ defer stdinPipe.Close()
+ if _, err := io.Copy(stdinPipe, bodyReader); err != nil {
+ gs.Log("failed to copy to stdin pipe: %s", err)
+ }
+ })
+
+ // copy output to response
+ wg.Go(func() {
+ defer stdoutPipe.Close()
+ if _, err := io.Copy(gs.newWriteFlusher(w), stdoutPipe); err != nil {
+ gs.Log("failed to copy to write flusher: %s", err)
+ }
+ })
+
+ wg.Wait()
+
+ if err := cmd.Wait(); err != nil {
+ return fmt.Errorf("git-receive-pack failed: %w, stderr: %s", err, stderr.String())
+ }
+
+ return nil
+}
+
+// updateHEAD updates the repository HEAD based on the latest state announcement
+func (gs *GraspServer) updateHEAD(ctx context.Context, pubkey nostr.PubKey, repoName, repoPath string) error {
+ if gs.Relay.QueryStored == nil {
+ return fmt.Errorf("no QueryStored function")
+ }
+
+ // query for the latest state event
+ var latestState *nip34.RepositoryState
+ for evt := range gs.Relay.QueryStored(ctx, nostr.Filter{
+ Kinds: []nostr.Kind{nostr.KindRepositoryState},
+ Authors: []nostr.PubKey{pubkey},
+ Tags: nostr.TagMap{"d": []string{repoName}},
+ Limit: 1,
+ }) {
+ state := nip34.ParseRepositoryState(evt)
+ latestState = &state
+ break
+ }
+
+ if latestState == nil || latestState.HEAD == "" {
+ // no state or no HEAD specified
+ return nil
+ }
+
+ // verify the HEAD branch exists in the state
+ if _, exists := latestState.Branches[latestState.HEAD]; !exists {
+ return fmt.Errorf("HEAD branch %s not found in state", latestState.HEAD)
+ }
+
+ // update HEAD using git symbolic-ref
+ cmd := exec.Command("git", "symbolic-ref", "HEAD", "refs/heads/"+latestState.HEAD)
+ cmd.Dir = repoPath
+ if output, err := cmd.CombinedOutput(); err != nil {
+ return fmt.Errorf("failed to update HEAD: %w, output: %s", err, string(output))
+ }
+ return nil
+}
+
+// cleanupMergedPatches removes refs/nostr/ refs that have been merged into branches
+func (gs *GraspServer) cleanupMergedPatches(ctx context.Context, pubkey nostr.PubKey, repoName, repoPath string) {
+ // use background context since request context will be cancelled
+ ctx = context.Background()
+
+ // wait 20 minutes before cleanup to allow events to propagate
+ time.Sleep(20 * time.Minute)
+
+ if gs.Relay.QueryStored == nil {
+ return
+ }
+
+ // get current state to know which branches exist
+ var state *nip34.RepositoryState
+ for evt := range gs.Relay.QueryStored(ctx, nostr.Filter{
+ Kinds: []nostr.Kind{nostr.KindRepositoryState},
+ Authors: []nostr.PubKey{pubkey},
+ Tags: nostr.TagMap{"d": []string{repoName}},
+ Limit: 1,
+ }) {
+ parsed := nip34.ParseRepositoryState(evt)
+ state = &parsed
+ break
+ }
+
+ if state == nil {
+ return
+ }
+
+ // list all refs/nostr/* refs
+ cmd := exec.Command("git", "for-each-ref", "--format=%(refname)", "refs/nostr")
+ cmd.Dir = repoPath
+ output, err := cmd.Output()
+ if err != nil {
+ // no refs/nostr refs, nothing to clean up
+ return
+ }
+
+ refs := strings.Split(strings.TrimSpace(string(output)), "\n")
+ for _, ref := range refs {
+ if ref == "" {
+ continue
+ }
+
+ eventId := strings.TrimPrefix(ref, "refs/nostr/")
+ id, err := nostr.IDFromHex(eventId)
+ if err != nil {
+ return
+ }
+
+ // check if there's still a valid patch event with a "c" tag referencing this commit
+ hasValidEvent := false
+ for evt := range gs.Relay.QueryStored(ctx, nostr.Filter{
+ IDs: []nostr.ID{id},
+ }) {
+ // check if event has a "c" tag
+ for _, tag := range evt.Tags {
+ if tag[0] == "c" && len(tag) > 1 {
+ hasValidEvent = true
+ break
+ }
+ }
+ break
+ }
+
+ if !hasValidEvent {
+ // no valid event, delete the ref
+ cmd := exec.Command("git", "update-ref", "-d", ref)
+ cmd.Dir = repoPath
+ if err := cmd.Run(); err != nil {
+ gs.Log("failed to delete ref %s: %s\n", ref, err)
+ } else {
+ gs.Log("deleted ref %s (no corresponding event)\n", ref)
+ }
+ continue
+ }
+
+ // check if the commit is merged into any branch
+ for branchName, commitId := range state.Branches {
+ // get the commit ID for this ref
+ cmd := exec.Command("git", "rev-parse", ref)
+ cmd.Dir = repoPath
+ refCommit, err := cmd.Output()
+ if err != nil {
+ continue
+ }
+
+ // check if ref commit is ancestor of branch head
+ cmd = exec.Command("git", "merge-base", "--is-ancestor", strings.TrimSpace(string(refCommit)), commitId)
+ cmd.Dir = repoPath
+ if err := cmd.Run(); err == nil {
+ // it's merged! delete the ref
+ cmd := exec.Command("git", "update-ref", "-d", ref)
+ cmd.Dir = repoPath
+ if err := cmd.Run(); err != nil {
+ gs.Log("failed to delete ref %s: %s\n", ref, err)
+ } else {
+ gs.Log("deleted ref %s (merged into %s)\n", ref, branchName)
+ }
+ break
+ }
+ }
+ }
+}