package hook import ( "context" "fmt" "io" "strings" "time" "github.com/barerepo/server/internal/gitx" "github.com/barerepo/server/internal/pktline" "github.com/barerepo/server/internal/proposal" "github.com/barerepo/server/internal/repocfg" "github.com/barerepo/server/internal/store" "github.com/barerepo/server/internal/thread" ) // ProcReceive turns a push to refs/proposals/new into refs/proposals/47, over pkt-line. func ProcReceive(ctx context.Context, e Env, stdin io.Reader, stdout, out io.Writer) error { r := pktline.NewReader(stdin) w := pktline.NewWriter(stdout) if err := negotiate(r, w); err != nil { return err } commands, err := readCommands(r) if err != nil { return err } cfg, _ := repocfg.Load(ctx, e.Dir) for _, c := range commands { res := s.handle(ctx, e, cfg, c, out) if res.err != "" { if err := w.Write("ng " + c.Ref + " " + res.err); err != nil { return err } continue } if err := w.Write("ok " + c.Ref); err != nil { return err } // Say where the push landed, so the client names the proposal and not the magic ref. if res.ref != "" && res.ref != c.Ref { if err := w.Write("option refname " + res.ref); err != nil { return err } } if res.old != "" { if err := w.Write("option old-oid " + res.old); err != nil { return err } } if res.forced { if err := w.Write("option forced-update"); err != nil { return err } } } return w.Flush() } // now is the one clock this file reads, so a test can hold it still. var now = time.Now // s exists only to group the handling; proc-receive has no state of its own. var s procReceive type procReceive struct{} type command struct{ Old, New, Ref string } func (c command) Creating() bool { return strings.Trim(c.Old, "0") == "" } func (c command) Deleting() bool { return strings.Trim(c.New, "0") == "" } type result struct { ref string old string forced bool err string } // negotiate agrees on protocol version 1. func negotiate(r *pktline.Reader, w *pktline.Writer) error { for { line, err := r.Read() if err == pktline.ErrFlush { break } if err != nil { return err } // The line is "version=1" and may carry capabilities after a NUL. if v, _, _ := strings.Cut(line, "\x00"); v != "version=1" { return fmt.Errorf("unsupported proc-receive version %q", v) } } if err := w.Write("version=1"); err != nil { return err } return w.Flush() } func readCommands(r *pktline.Reader) ([]command, error) { var out []command for { line, err := r.Read() if err == pktline.ErrFlush { return out, nil } if err != nil { return nil, err } f := strings.Fields(line) if len(f) < 3 { return nil, fmt.Errorf("malformed proc-receive command %q", line) } out = append(out, command{Old: f[0], New: f[1], Ref: f[2]}) } } // handle does the work for one command and says what to report. func (procReceive) handle(ctx context.Context, e Env, cfg repocfg.Config, c command, out io.Writer) result { switch { case c.Ref == proposal.NewRef: return openProposal(ctx, e, cfg, c, out) case proposal.Number(c.Ref) > 0: return updateProposal(ctx, e, cfg, c, out) default: return result{err: c.Ref + " is not a proposal ref"} } } // openProposal allocates a number and creates the ref. func openProposal(ctx context.Context, e Env, cfg repocfg.Config, c command, out io.Writer) result { if c.Deleting() { return result{err: "refs/proposals/new is not a ref, so it cannot be deleted"} } if !cfg.MayPropose(e.Owner, e.Account) { return result{err: "this repository does not accept proposals from you"} } n, err := proposal.Allocate(ctx, e.Dir) if err != nil { return result{err: err.Error()} } ref := proposal.Ref(n) if _, err := gitx.Run(ctx, e.Dir, "update-ref", ref, c.New, ""); err != nil { return result{err: err.Error()} } // A proposal is a thread with a ref, so opening one opens the thread. Chapter 13. subject, err := gitx.Run(ctx, e.Dir, "log", "--max-count=1", "--format=%s", c.New) if err != nil { subject = "" } meta := thread.Meta{ Title: strings.TrimSpace(subject), State: thread.Open, Ref: ref, Author: e.Account, Opened: now(), } if err := thread.Create(ctx, e.Dir, n, meta, "", thread.Comment{}); err != nil { return result{err: err.Error()} } record(ctx, store.Event{ Kind: store.ProposalOpened, Actor: e.Account, Repo: e.Owner + "/" + e.Name, Number: n, Ref: ref, Title: meta.Title, }) took(ctx, e.Account, e.Owner+"/"+e.Name, n) fmt.Fprintf(out, "\nproposal %d opened.\n", n) if e.URL != "" { fmt.Fprintf(out, " %s/thread/%d\n", e.URL, n) } fmt.Fprintf(out, " update it later with: git push -f origin HEAD:%s\n\n", ref) return result{ref: ref, old: c.Old} } // updateProposal expects force, because chapter 12 updates a proposal by rewriting it. func updateProposal(ctx context.Context, e Env, cfg repocfg.Config, c command, out io.Writer) result { n := proposal.Number(c.Ref) if c.Ref != proposal.Ref(n) { return result{err: "a revision ref is written by the server, not by a push"} } // git hands proc-receive the client's old-oid and checks it against nothing, so read the ref: chapter 12's retained revision and the thread's record both hang off what was really replaced. old := tipOf(ctx, e.Dir, c.Ref) creating := strings.Trim(old, "0") == "" // An owner pushing a mirror is restoring, and chapter 40.3 depends on it working. if e.Account != "" && e.Account == e.Owner && creating { if _, err := gitx.Run(ctx, e.Dir, "update-ref", c.Ref, c.New, ""); err != nil { return result{err: err.Error()} } return result{ref: c.Ref, old: old} } author, err := authorOf(ctx, e.Dir, n) if err != nil { return result{err: err.Error()} } if author == "" { // Nothing records who opened this ref, so only push access may touch it. if !cfg.MayPush(e.Owner, e.Account) { return result{err: fmt.Sprintf("proposal %d has no recorded author, so only somebody with push access may update it", n)} } author = e.Account } // Chapter 12: the author, plus anyone with push access. if e.Account != author && !cfg.MayPush(e.Owner, e.Account) { return result{err: fmt.Sprintf("proposal %d belongs to %s", n, author)} } // Retain the replaced tip, so comments anchored to it do not dangle. Chapters 12 and 43.5. forced := false expect := old if creating { // An empty old value means the ref must not exist, which is what a create asks for. expect = "" } else { if _, err := gitx.Run(ctx, e.Dir, "merge-base", "--is-ancestor", old, c.New); err != nil { forced = true } k, err := nextRevision(ctx, e.Dir, n) if err == nil { if _, err := gitx.Run(ctx, e.Dir, "update-ref", proposal.RevisionRef(n, k), old, ""); err == nil { fmt.Fprintf(out, "kept the previous version as %s\n", proposal.RevisionRef(n, k)) } } } // The tip the revision was kept from rides in the write, so a push landing in between is refused rather than replacing a version nothing retained. if _, err := gitx.Run(ctx, e.Dir, "update-ref", c.Ref, c.New, expect); err != nil { return result{err: err.Error()} } // Chapter 12 records each push as a revision in the thread, or a reviewer cannot see it moved. if !creating { note := thread.Comment{Author: e.Account, Time: time.Now(), Body: fmt.Sprintf("pushed revision %d.", proposal.CurrentRevision(e.Dir, n))} if forced { note.Body = fmt.Sprintf("pushed revision %d, rewriting the last one.", proposal.CurrentRevision(e.Dir, n)) } if err := thread.Reply(ctx, e.Dir, n, c.New, note); err != nil { fmt.Fprintf(out, "could not record the revision in thread %d: %v\n", n, err) } } record(ctx, store.Event{ Kind: store.ProposalUpdated, Actor: e.Account, Repo: e.Owner + "/" + e.Name, Number: n, Ref: c.Ref, }) took(ctx, e.Account, e.Owner+"/"+e.Name, n) fmt.Fprintf(out, "proposal %d updated.\n", n) return result{ref: c.Ref, old: old, forced: forced} } // tipOf is what a ref actually holds, and the all-zero id is git's word for "it does not". func tipOf(ctx context.Context, dir, ref string) string { out, err := gitx.Run(ctx, dir, "rev-parse", "--verify", "--quiet", ref) if err != nil { return Zero } return strings.TrimSpace(out) } // authorOf reads the thread's meta blob, never the commit author, which any pusher can type. func authorOf(ctx context.Context, dir string, n int) (string, error) { out, err := gitx.Run(ctx, dir, "for-each-ref", "--format=%(refname)", proposal.Ref(n)) if err != nil { return "", err } if strings.TrimSpace(out) == "" { return "", nil } meta, exists, err := thread.ReadMeta(ctx, dir, n) if err != nil { return "", err } if !exists || meta.Author == "" { // A proposal ref with no thread came from a mirror restore, which only the owner writes. return "", nil } return meta.Author, nil } // nextRevision is the lowest unused revision slot for proposal n. func nextRevision(ctx context.Context, dir string, n int) (int, error) { // Refs are files, per chapter 6, so counting them costs no process on the push path. return proposal.CurrentRevision(dir, n), nil } // took records participation, which is the subscription, so they hear about it later. func took(ctx context.Context, account, repo string, n int) { if Queue == nil { return } _ = Queue.TookPart(ctx, account, repo, n) }