// Package hook is what git runs around a push, and its stderr is the pusher's terminal. package hook import ( "bufio" "bytes" "context" "errors" "fmt" "io" "net/url" "os" "strings" "time" "github.com/barerepo/server/internal/config" "github.com/barerepo/server/internal/gitx" "github.com/barerepo/server/internal/proposal" "github.com/barerepo/server/internal/repo" "github.com/barerepo/server/internal/repocfg" "github.com/barerepo/server/internal/run" "github.com/barerepo/server/internal/search" "github.com/barerepo/server/internal/store" "github.com/barerepo/server/internal/thread" "github.com/barerepo/server/internal/workflow" ) // ErrRejected means the reason is already in the pusher's terminal, so the caller adds nothing. var ErrRejected = errors.New("rejected") // reject explains and refuses, in that order. func reject(out io.Writer, format string, args ...any) error { fmt.Fprintf(out, format+"\n", args...) return ErrRejected } // Update is one line of a hook's stdin: . type Update struct { Old, New, Ref string } // Zero is the all-zero object id git uses for "did not exist" and "deleted". const Zero = "0000000000000000000000000000000000000000" func (u Update) Creating() bool { return strings.Trim(u.Old, "0") == "" } func (u Update) Deleting() bool { return strings.Trim(u.New, "0") == "" } // Env is what ssh and http set before git-receive-pack, and git passes it to the hooks. type Env struct { Account string // who is pushing. empty is impossible on a write. Owner string Name string Dir string URL string // where this repository is on the web Created bool // this push created the repository // Config is where the server read its configuration, which a stripped hook cannot find alone. Config string } func EnvFromOS() Env { dir, _ := os.Getwd() return Env{ Account: os.Getenv("BAREREPO_ACCOUNT"), Owner: os.Getenv("BAREREPO_OWNER"), Name: os.Getenv("BAREREPO_NAME"), URL: os.Getenv("BAREREPO_URL"), Created: os.Getenv("BAREREPO_CREATED") == "1", Config: os.Getenv("BAREREPO_CONFIG"), Dir: dir, } } // Vars renders an Env for exec.Cmd. git passes these through to the hooks. func (e Env) Vars() []string { created := "0" if e.Created { created = "1" } return []string{ "BAREREPO_ACCOUNT=" + e.Account, "BAREREPO_OWNER=" + e.Owner, "BAREREPO_NAME=" + e.Name, "BAREREPO_URL=" + e.URL, "BAREREPO_CREATED=" + created, "BAREREPO_CONFIG=" + e.Config, } } // ReadUpdates parses a hook's stdin. func ReadUpdates(r io.Reader) ([]Update, error) { var out []Update sc := bufio.NewScanner(r) for sc.Scan() { f := strings.Fields(sc.Text()) if len(f) != 3 { return nil, fmt.Errorf("malformed hook input") } out = append(out, Update{Old: f[0], New: f[1], Ref: f[2]}) } return out, sc.Err() } // PreReceive decides the whole push, all or nothing, because a half push is harder to reason about. func PreReceive(ctx context.Context, e Env, ups []Update, out io.Writer) error { // Chapter 11: typos create repositories, so print the URL where the mistake is visible. if e.Created { fmt.Fprintf(out, "\ncreated %s/%s. it is private.\n", e.Owner, e.Name) if e.URL != "" { fmt.Fprintf(out, " %s\n", e.URL) } fmt.Fprintf(out, " if that name is a typo, delete it on the config page.\n\n") } cfg, cfgErr := repocfg.Load(ctx, e.Dir) if cfgErr != nil { // A malformed file must not lock anyone out. Chapter 14. fmt.Fprintf(out, "warning: %s does not parse\n", repocfg.Path) fmt.Fprintf(out, "warning: %v\n", cfgErr) if cfg.FellBackTo != "" { fmt.Fprintf(out, "warning: the settings from %s are still in force\n", short(cfg.FellBackTo)) } else { fmt.Fprintf(out, "warning: no earlier version parses either, so the defaults are in force\n") } } // Judged once, and the owner is exempt because unarchiving arrives by push. Chapter 21.3. if cfg.Repo.Archived && e.Account != e.Owner { return reject(out, "this repository is archived.\nthe owner can unarchive it in %s", repocfg.Path) } head, err := repo.HeadBranch(ctx, e.Dir) if err != nil { // An empty repository's HEAD points nowhere until the first push gives it something. head = "" } // A typo is best reported by the push that makes it, not by whoever pushes next. Chapter 14. for _, u := range ups { if head == "" || u.Ref != "refs/heads/"+head || u.New == Zero { continue } if err := repocfg.ParseAt(ctx, e.Dir, u.New); err != nil { fmt.Fprintf(out, "warning: this push leaves %s so it does not parse\n", repocfg.Path) fmt.Fprintf(out, "warning: %v\n", err) fmt.Fprintf(out, "warning: the settings in force do not change until it parses again\n") } } // The owner may write any ref they own, or chapter 40.3's `git push --mirror` cannot restore. restoring := e.Account != "" && e.Account == e.Owner // Chapter 20.2's push limit is one number for the whole push, so every ref's objects are summed and counted once, or a tag beside its branch is charged twice. counted := map[string]bool{} // A commit reachable from two refs in one push is read once, the same as the byte count above. signedSeen := map[string]bool{} // A first commit has no parent to rebase onto, and the printed command has to be the one that works. signedRoot := map[string]bool{} var adding int64 for _, u := range ups { if !gitx.ValidRef(u.Ref) { return reject(out, "%s is not a usable ref name", u.Ref) } // Chapter 20.2: turning LFS off does not hold without a limit, and the limit names the file. if Limits.MaxBlobMB > 0 || Limits.MaxPushMB > 0 { adds := inspect(ctx, e.Dir, u, Limits.MaxBlobMB, counted) if len(adds.Over) > 0 { return reject(out, "%s", tooBig(adds.Over, Limits.MaxBlobMB)) } adding += adds.Bytes if Limits.MaxPushMB > 0 && adding > int64(Limits.MaxPushMB)<<20 { return reject(out, "%s", tooMuch(adding, Limits.MaxPushMB)) } } if restoring && !strings.HasPrefix(u.Ref, "refs/heads/") { // Branches still meet the rules below, which protect the owner from themselves. continue } switch { case u.Ref == proposal.NewRef || proposal.Number(u.Ref) > 0: // proc-receive owns this namespace, so only ask whether the account may propose. if !cfg.MayPropose(e.Owner, e.Account) { return reject(out, "this repository does not accept proposals from you") } // Chapter 27: rule 5 is an open door, and the cap is how it is defended without closing. if u.Ref == proposal.NewRef && Limits.MaxOpenProposals > 0 { open := thread.OpenProposalsBy(ctx, e.Dir, e.Account) if open >= Limits.MaxOpenProposals { return reject(out, "you have %d proposals open on %s/%s. the limit is %d.\n"+ "land or close one, then push this again.", open, e.Owner, e.Name, Limits.MaxOpenProposals) } } case strings.HasPrefix(u.Ref, "refs/heads/"), strings.HasPrefix(u.Ref, "refs/tags/"): // An official repository distributes what it holds, so every commit that lands in one is signed. Nothing else on the server is affected. if cfg.Access.RequireSigned && !u.Deleting() { if bad := unsignedIn(ctx, e.Dir, u, signedSeen, signedRoot); len(bad) > 0 { return refuseUnsigned(out, u.Ref, bad, signedRoot) } } if !cfg.MayPush(e.Owner, e.Account) { // The push-rejected page's wording, so the terminal and the page never differ. fmt.Fprintf(out, "you pushed to %s\n", u.Ref) fmt.Fprintf(out, "%s [access] push = %s · you are %s\n", repocfg.Path, repocfg.List(cfg.Access.Push), repocfg.Who(e.Account)) fmt.Fprintf(out, "\npush here instead. it needs no permission.\n") fmt.Fprintf(out, "\n git push origin HEAD:refs/proposals/new\n") // Chapter 24: terminals scroll, so the page is only reachable if the hook prints it. if e.URL != "" { // The commit and the moment travel with the link, because push-rejected.html heads the page with them and the request knows neither. fmt.Fprintf(out, "\n %s/rejected?ref=%s&sha=%s&at=%d\n", e.URL, url.QueryEscape(u.Ref), u.New, time.Now().Unix()) } return ErrRejected } branch := strings.TrimPrefix(u.Ref, "refs/heads/") if strings.HasPrefix(u.Ref, "refs/heads/") && head != "" { if u.Deleting() && !cfg.DeleteAllowed(branch, head) { return reject(out, "%s is the default branch and cannot be deleted", branch) } if !u.Deleting() && !u.Creating() { forced, err := isForce(ctx, e.Dir, u) if err != nil { return err } if forced && !cfg.ForcePushAllowed(branch, head) { return reject(out, "force-push to %s, the default branch, would destroy other people's work.\n"+ "allow it in %s under [access] allow_force_push if you mean it.", branch, repocfg.Path) } } // Chapter 37.4: the default branch takes a commit only after the named runs pass. if !u.Deleting() && branch == head && e.Account != e.Owner { missing := runsMissing(ctx, e.Dir, u.New, cfg.Proposals.RequireRuns) if len(missing) > 0 { fmt.Fprintf(out, "%s takes a commit only after %s passes\n", branch, repocfg.List(cfg.Proposals.RequireRuns)) fmt.Fprintf(out, "%s [proposals] require_runs · %s has not passed on %s\n", repocfg.Path, strings.Join(missing, ", "), short(u.New)) fmt.Fprintf(out, "\npush the commit as a proposal and it builds there first.\n") fmt.Fprintf(out, "\n git push origin HEAD:refs/proposals/new\n") // The runs page is the only place that says why a build has not passed. if e.URL != "" { fmt.Fprintf(out, "\n %s/runs\n", e.URL) } return ErrRejected } } } case strings.HasPrefix(u.Ref, "refs/notes/threads/"): if !cfg.MayRead(e.Owner, e.Account) { return reject(out, "you cannot read %s/%s", e.Owner, e.Name) } // Chapter 13 only ever appends comment records, so a push missing one is dropping it, and a fast-forward proves nothing because a commit can keep the parent and hand back an emptier tree. if !u.Creating() && !cfg.MayPush(e.Owner, e.Account) { kept := thread.RecordsAt(ctx, e.Dir, u.New) added := thread.OnlyAppends(ctx, e.Dir, u.Old, u.New) for record := range thread.RecordsAt(ctx, e.Dir, u.Old) { if kept[record] || added { continue } n := thread.NumberOf(u.Ref) // Chapter 35.5 already answers this, so the answer is what it prints. return reject(out, "thread %d holds replies this push does not, and landing it would drop them.\n"+ "take them first, then write yours after them.\n\n"+ " git fetch origin %s\n"+ " git update-ref %s FETCH_HEAD\n"+ " git notes --ref=threads/%d append -m \"...\"\n"+ " git push origin %s", n, u.Ref, u.Ref, n, u.Ref) } } // Chapter 12 reads a proposal's author out of the meta blob, so a reader who may comment must not be able to rewrite whose thread it is. if before, had := thread.MetaAt(ctx, e.Dir, u.Old); had { after, _ := thread.MetaAt(ctx, e.Dir, u.New) if after.Render() != before.Render() && e.Account != before.Author && !cfg.MayPush(e.Owner, e.Account) { // Chapter 35.5 pushes comments from a clone, and nothing there touches meta. return reject(out, "the meta blob records who opened thread %d and where it stands.\n"+ "a push here adds comments; the thread page changes the rest.", thread.NumberOf(u.Ref)) } } else if after, made := thread.MetaAt(ctx, e.Dir, u.New); made { // The first meta names whoever pushed it, and the owner restoring a mirror carries everyone's threads, which chapter 40.3 needs. if after.Author != "" && after.Author != e.Account && !cfg.MayPush(e.Owner, e.Account) { return reject(out, "thread %d would open as %s, and this push is %s.\n"+ "open it as yourself, or ask somebody who may push here.", thread.NumberOf(u.Ref), after.Author, e.Account) } } case strings.HasPrefix(u.Ref, "refs/notes/runs"): // Chapter 18: the server writes these, through job completion. return reject(out, "build results are written by the server") default: return reject(out, "%s is not a namespace you can write", u.Ref) } } return nil } // unsignedIn names the commits this push adds that carry no signature, newest first. func unsignedIn(ctx context.Context, dir string, u Update, seen, rooted map[string]bool) []string { // What the ref gains, not what the repository gains, or a commit parked on a proposal ref lands here unread. args := []string{"rev-list", u.New, "--not", u.Old} if u.Creating() { args = []string{"rev-list", u.New} } out, err := gitx.Run(ctx, dir, args...) if err != nil { return nil } var specs []string for _, sha := range strings.Fields(out) { if seen[sha] { continue } seen[sha] = true specs = append(specs, sha) } if len(specs) == 0 { return nil } // One process for the whole push, because a signature is a header on the object and not a check. objs, err := gitx.Batch(ctx, dir, specs) if err != nil { return nil } good := verified(ctx, dir, specs) var bad []string for _, sha := range specs { obj := objs[sha] if obj == nil || !hasSignature(obj.Body) || (good != nil && !good[sha]) { bad = append(bad, sha) rooted[sha] = obj != nil && !hasParent(obj.Body) } } return bad } // AllowedSigners names the keys a commit signature is checked against, and empty means the header is all that is read. var AllowedSigners string // Keyring is where published gpg keys live, so a gpg signature is checked the same way an ssh one is. var Keyring string // verified asks git which of these signatures hold, and answers nil when nothing can check them. func verified(ctx context.Context, dir string, specs []string) map[string]bool { if AllowedSigners == "" { return nil } if _, err := os.Stat(AllowedSigners); err != nil { // Every commit fails, because a repository that asked for signatures must not quietly stop checking them. return map[string]bool{} } // The revisions go in on stdin, because a push of ten thousand commits is a command line too long to run. args := []string{"-c", "gpg.ssh.allowedSignersFile=" + AllowedSigners, "log", "--no-walk", "--format=%H %G?", "--stdin"} var out, errb bytes.Buffer extra := []string{} if Keyring != "" { if _, err := os.Stat(Keyring); err == nil { extra = append(extra, "GNUPGHOME="+Keyring) } } in := strings.NewReader(strings.Join(specs, "\n") + "\n") if err := gitx.Pipe(ctx, dir, in, &out, &errb, extra, args...); err != nil { // Refusing, because a check that failed to run has not said the signatures are good. return map[string]bool{} } good := map[string]bool{} for _, line := range strings.Split(out.String(), "\n") { sha, flag, ok := strings.Cut(strings.TrimSpace(line), " ") if !ok { continue } // G is a good signature by a key on the list, and every other letter is a reason to refuse. good[sha] = flag == "G" } return good } // hasParent tells a first commit from the rest, because a rebase onto a first commit needs --root. func hasParent(body string) bool { for _, line := range strings.Split(body, "\n") { if line == "" { return false } if strings.HasPrefix(line, "parent ") { return true } } return false } // hasSignature looks for the header git writes for both gpg and ssh signatures. func hasSignature(body string) bool { for _, line := range strings.Split(body, "\n") { if line == "" { // The headers end at the first blank line, and the message can say anything it likes. return false } if strings.HasPrefix(line, "gpgsig") { return true } } return false } // refuseUnsigned prints what is unsigned and the commands that fix it. func refuseUnsigned(out io.Writer, ref string, bad []string, rooted map[string]bool) error { fmt.Fprintf(out, "\n%s takes only signed commits.\n", ref) fmt.Fprintf(out, "%s [access] require_signed_commits = true\n\n", repocfg.Path) if _, err := os.Stat(AllowedSigners); AllowedSigners != "" && err != nil { fmt.Fprintf(out, "\n%s takes only signed commits, and this server cannot check a signature.\n", ref) fmt.Fprintf(out, "the keys it checks against are missing. an operator fixes it with:\n\n") fmt.Fprintf(out, " barerepo doctor\n") return ErrRejected } if len(bad) == 1 { fmt.Fprintf(out, "one commit in this push is not signed by a key barerepo holds:\n") } else { fmt.Fprintf(out, "%d commits in this push are not signed by a key barerepo holds:\n", len(bad)) } for i, sha := range bad { if i == 5 { fmt.Fprintf(out, " and %d more\n", len(bad)-5) break } fmt.Fprintf(out, " %s\n", short(sha)) } fmt.Fprintf(out, "\nsign what you push from now on:\n\n") fmt.Fprintf(out, " git config gpg.format ssh\n") fmt.Fprintf(out, " git config user.signingkey ~/.ssh/id_ed25519.pub\n") fmt.Fprintf(out, " git config commit.gpgsign true\n") fmt.Fprintf(out, "\nthen sign the ones you already wrote:\n\n") oldest := bad[len(bad)-1] onto := short(oldest) + "~1" if rooted[oldest] { onto = "--root" } fmt.Fprintf(out, " git rebase --exec \"git commit --amend --no-edit -S\" %s\n", onto) return ErrRejected } // runsMissing names the required runs that have not passed on one commit, in the configured order. func runsMissing(ctx context.Context, dir, sha string, want []string) []string { if len(want) == 0 { return nil } recs, err := run.For(ctx, dir, sha) if err != nil { // A commit with no notes has no runs, which is exactly the case this rule exists for. return want } passed := make(map[string]bool, len(recs)) for _, rec := range recs { if !rec.Failed() { passed[rec.Name] = true } } var missing []string for _, name := range want { if !passed[name] { missing = append(missing, name) } } return missing } // isForce reports whether this update drops commits, which a fast-forward never does. func isForce(ctx context.Context, dir string, u Update) (bool, error) { _, err := gitx.Run(ctx, dir, "merge-base", "--is-ancestor", u.Old, u.New) if err == nil { return false, nil } // merge-base exits 1 for "not an ancestor", and a missing object is the empty repository. if _, e := gitx.Run(ctx, dir, "cat-file", "-e", u.Old+"^{commit}"); e != nil { return false, nil } return true, nil } // PostReceive runs after the refs moved and can refuse nothing. func PostReceive(ctx context.Context, e Env, ups []Update, out io.Writer) error { if err := adoptHead(ctx, e, ups); err != nil { return err } followDefaultBranch(ctx, e, out) if err := closeMergedProposals(ctx, e, ups, out); err != nil { return err } recordPushes(ctx, e, ups) repairNotes(ctx, e, ups) recordNotes(ctx, e, ups) indexPush(ctx, e, ups) return queueBuilds(ctx, e, ups, out) } // Docs is the search index, set once the database is open, and no index means search finds nothing. var Docs search.Index // indexPush keeps chapter 17's index current, and a failure here must never fail the push. func indexPush(ctx context.Context, e Env, ups []Update) { if Docs == nil { return } branch, err := repo.HeadBranch(ctx, e.Dir) if err != nil { return } // A file that will not parse still has a fallback, and chapter 14 will not let a typo take the repository out of search until somebody notices. cfg, _ := repocfg.Load(ctx, e.Dir) t := search.Target{Owner: e.Owner, Name: e.Name, Dir: e.Dir, Ref: branch, Config: cfg} if err := search.IndexMeta(ctx, Docs, t); err != nil { fmt.Fprintf(os.Stderr, "barerepo: index: %v\n", err) } // Code lives at the tip of the default branch, so no other ref changes what a code search finds. head := "refs/heads/" + branch for _, u := range ups { if u.Ref == head && !u.Deleting() { if err := search.IndexPush(ctx, Docs, t, u.Old, u.New); err != nil { fmt.Fprintf(os.Stderr, "barerepo: index: %v\n", err) } return } } } // Queuer is what post-receive needs from the database, passed in because the hook is its own process. type Queuer interface { QueueJob(ctx context.Context, repo, ref, sha, command, image string) (int64, error) QueueJobFor(ctx context.Context, repo, ref, sha, command, image string, labels []string, name string) (int64, error) RunnersOf(ctx context.Context, repo string) ([]store.Runner, error) Record(ctx context.Context, e store.Event) error TookPart(ctx context.Context, account, repo string, number int) error } // Queue is set once the database is open, and no queue means builds are switched off. var Queue Queuer // Limits is the server's limits, set once its config is read, because a hook is its own process. var Limits config.Limits // queueBuilds makes a job per new tip, from [build] command or from a workflow. Chapters 15 and 15A. func queueBuilds(ctx context.Context, e Env, ups []Update, out io.Writer) error { if Queue == nil { return nil } repo := e.Owner + "/" + e.Name cfg, _ := repocfg.Load(ctx, e.Dir) command := strings.TrimSpace(cfg.Build.Command) for _, u := range ups { if u.Deleting() || strings.HasPrefix(u.Ref, "refs/notes/") { continue } // The repository's own file wins, because it is barerepo's own answer and it is one command. if command != "" { if _, err := Queue.QueueJob(ctx, repo, u.Ref, u.New, command, cfg.Build.Image); err != nil { fmt.Fprintf(out, "could not queue a build for %s: %v\n", u.Ref, err) continue } fmt.Fprintf(out, "queued a build for %s\n", u.Ref) continue } queueWorkflows(ctx, e, repo, u, out) } return nil } // queueWorkflows runs what it can of .github/workflows, and says plainly what it cannot. Chapter 15A. func queueWorkflows(ctx context.Context, e Env, repo string, u Update, out io.Writer) { jobs, err := workflow.Load(ctx, e.Dir, u.New, workflow.Context{Repo: repo, Ref: u.Ref, SHA: u.New}) if err != nil || len(jobs) == 0 { return } runners, err := Queue.RunnersOf(ctx, repo) if err != nil { runners = nil } for _, j := range jobs { for _, s := range j.Skipped { // Never a quiet omission, because a green build has to mean what it says. fmt.Fprintf(out, "%s: skipped %s, which %s\n", j.Path, s.Step, s.Reason) } if !j.Runnable() { continue } if !anyRunnerFor(runners, j.RunsOn) { declineJob(e, j, out) continue } if _, err := Queue.QueueJobFor(ctx, repo, u.Ref, u.New, j.Script, j.Image, j.RunsOn, j.Name); err != nil { fmt.Fprintf(out, "could not queue %s: %v\n", j.Name, err) continue } fmt.Fprintf(out, "queued %s from %s for %s\n", j.Name, j.Path, u.Ref) } } // anyRunnerFor reports whether a machine is attached that could take this job. func anyRunnerFor(runners []store.Runner, want []string) bool { for _, r := range runners { if r.Satisfies(want) { return true } } return false } // declineJob says which machine is missing and where to attach one, rather than queueing forever. func declineJob(e Env, j workflow.Job, out io.Writer) { asked := strings.Join(j.RunsOn, ", ") if asked == "" { asked = "any" } fmt.Fprintf(out, "%s wants a %s machine and none is attached.\n", j.Name, asked) // Chapter 11's rule about typos applies here too: print the page that fixes it. if e.URL != "" { fmt.Fprintf(out, " attach one: %s/runners/new\n", e.URL) } } // adoptHead corrects a push-created repository's guessed HEAD to the branch actually pushed. func adoptHead(ctx context.Context, e Env, ups []Update) error { head, err := gitx.Run(ctx, e.Dir, "symbolic-ref", "HEAD") if err != nil { return nil } head = strings.TrimSpace(head) // If HEAD already resolves to a commit, it is not a guess any more. if _, err := gitx.Run(ctx, e.Dir, "rev-parse", "--verify", "--quiet", head); err == nil { return nil } for _, u := range ups { if !u.Deleting() && strings.HasPrefix(u.Ref, "refs/heads/") { _, err := gitx.Run(ctx, e.Dir, "symbolic-ref", "HEAD", u.Ref) return err } } return nil } // list renders as .barerepo/config spells it, so the printed line is the line the reader finds. // followDefaultBranch moves HEAD to what the pushed config names, which is all chapter 33.6 asks. func followDefaultBranch(ctx context.Context, e Env, out io.Writer) { // The fallback names the branch, so a config that will not parse does not also stop a proposal being seen as merged. Chapter 14. cfg, _ := repocfg.Load(ctx, e.Dir) if cfg.Repo.DefaultBranch == "" { return } want := "refs/heads/" + cfg.Repo.DefaultBranch if !gitx.ValidRef(want) { return } now, err := gitx.Run(ctx, e.Dir, "symbolic-ref", "HEAD") if err != nil || strings.TrimSpace(now) == want { return } if _, err := gitx.Run(ctx, e.Dir, "rev-parse", "--verify", "--quiet", want); err != nil { // The branch is named and not pushed yet, so saying so beats moving HEAD to nothing. fmt.Fprintf(out, "%s names %s as the default branch and it does not exist here yet\n", repocfg.Path, cfg.Repo.DefaultBranch) return } if _, err := gitx.Run(ctx, e.Dir, "symbolic-ref", "HEAD", want); err != nil { return } fmt.Fprintf(out, "the default branch is %s now, as %s says\n", cfg.Repo.DefaultBranch, repocfg.Path) } // closeMergedProposals concludes, from a push, that a reachable proposal tip was merged. func closeMergedProposals(ctx context.Context, e Env, ups []Update, out io.Writer) error { head, err := repo.HeadBranch(ctx, e.Dir) if err != nil { return nil } for _, u := range ups { if u.Ref != "refs/heads/"+head || u.Deleting() { continue } open, err := openProposals(ctx, e.Dir) if err != nil { return err } if len(open) == 0 { return nil } // One rev-list and a membership test, not an is-ancestor call per proposal. Chapter 12. arrived, err := commitsIn(ctx, e.Dir, u) if err != nil { return err } for _, n := range open { tip, err := gitx.Run(ctx, e.Dir, "rev-parse", "--verify", "--quiet", proposal.Ref(n)) if err != nil { continue } tip = strings.TrimSpace(tip) if !arrived[tip] { // Outside this push's range, but the next check settles an earlier one. if _, err := gitx.Run(ctx, e.Dir, "merge-base", "--is-ancestor", tip, u.New); err != nil { continue } } if err := thread.SetState(ctx, e.Dir, n, thread.Merged, u.New); err != nil { fmt.Fprintf(out, "could not close thread %d: %v\n", n, err) continue } record(ctx, store.Event{ Kind: store.ProposalMerged, Actor: e.Account, Repo: e.Owner + "/" + e.Name, Number: n, Ref: proposal.Ref(n), }) fmt.Fprintf(out, "proposal %d is now reachable from %s. thread %d closed as merged.\n", n, head, n) } } return nil } // openProposals lists the numbers of proposals whose thread is still open. func openProposals(ctx context.Context, dir string) ([]int, error) { out, err := gitx.Run(ctx, dir, "for-each-ref", "--format=%(refname)", "refs/proposals/") if err != nil { return nil, err } var open []int for _, ref := range strings.Split(strings.TrimSpace(out), "\n") { n := proposal.Number(ref) if n == 0 { continue } m, exists, err := thread.ReadMeta(ctx, dir, n) if err != nil { return nil, err } if !exists || m.State == thread.Open { open = append(open, n) } } return open, nil } // commitsIn is the set of commits this ref update brought. func commitsIn(ctx context.Context, dir string, u Update) (map[string]bool, error) { spec := u.New if !u.Creating() { spec = u.Old + ".." + u.New } out, err := gitx.Run(ctx, dir, "rev-list", spec) if err != nil { return map[string]bool{}, nil } set := map[string]bool{} for _, line := range strings.Split(strings.TrimSpace(out), "\n") { if line != "" { set[line] = true } } return set, nil } // repairNotes gives a hand-pushed reply its author, since the thread page tells people to push one. func repairNotes(ctx context.Context, e Env, ups []Update) { for _, u := range ups { n := thread.NumberOf(u.Ref) if n == 0 || u.Deleting() { continue } // The old commit is still here, so what this push added is the difference and not a guess. was := thread.NotesAt(ctx, e.Dir, u.Old) // A note that cannot be repaired is still a note, and the push already happened. _ = thread.Repair(ctx, e.Dir, n, was, e.Account, time.Now()) } } // recordNotes gives a comment pushed from a clone the inbox line a comment written here gets. 19.2. func recordNotes(ctx context.Context, e Env, ups []Update) { if Queue == nil { return } repo := e.Owner + "/" + e.Name for _, u := range ups { n := thread.NumberOf(u.Ref) if n == 0 || u.Deleting() { continue } meta, ok := thread.MetaAt(ctx, e.Dir, u.New) if !ok { continue } before, had := thread.MetaAt(ctx, e.Dir, u.Old) kind := store.ThreadReplied switch { case !had: kind = store.ThreadOpened case meta.State != thread.Open && before.State == thread.Open: kind = store.ThreadClosed } record(ctx, store.Event{Kind: kind, Actor: e.Account, Repo: repo, Number: n, Title: meta.Title}) // Participation is subscription, so pushing a reply is how you start hearing about one. 19.2. if Queue != nil { _ = Queue.TookPart(ctx, e.Account, repo, n) } } } // record writes an event and never fails a push, because an inbox line is worth less. func record(ctx context.Context, ev store.Event) { if Queue == nil { return } _ = Queue.Record(ctx, ev) } // recordPushes notes what arrived, so the people who care can see it. func recordPushes(ctx context.Context, e Env, ups []Update) { if Queue == nil { return } repo := e.Owner + "/" + e.Name for _, u := range ups { if u.Deleting() || strings.HasPrefix(u.Ref, "refs/notes/") { continue } // A proposal push has its own event, and two inbox lines for one action is one too many. if proposal.Number(u.Ref) > 0 { continue } record(ctx, store.Event{ Kind: store.Pushed, Actor: e.Account, Repo: repo, Ref: u.Ref, }) } } // short is the seven characters a person reads a sha by, the same length the pages use. func short(sha string) string { if len(sha) > 7 { return sha[:7] } return sha }