// Package proposal is chapter 12: a change pushed to a ref, numbered by the repository itself. package proposal import ( "context" "fmt" "strconv" "strings" "github.com/barerepo/server/internal/gitx" ) // CounterRef is a ref, so update-ref makes allocation atomic and the number travels with the repo. const CounterRef = "refs/meta/counter" // maxRetries bounds the swap, where contention is two simultaneous pushes, not a herd. const maxRetries = 20 // Allocate returns the next number, shared by proposals and threads, which are one object. func Allocate(ctx context.Context, dir string) (int, error) { for attempt := 0; attempt < maxRetries; attempt++ { oldBlob, current, err := readCounter(ctx, dir) if err != nil { return 0, err } // Skip what is taken, because chapter 35.5 lets a push leave the counter behind. next := current + 1 for taken(ctx, dir, next) { next++ } newBlob, err := gitx.RunStdin(ctx, dir, strconv.Itoa(next)+"\n", "hash-object", "-w", "--stdin") if err != nil { return 0, err } newBlob = strings.TrimSpace(newBlob) args := []string{"update-ref", CounterRef, newBlob} // An empty old value means the ref must not exist, which makes the first allocation safe. args = append(args, oldBlob) if _, err := gitx.Run(ctx, dir, args...); err == nil { return next, nil } // Somebody else allocated between the read and the write. Read again. } return 0, fmt.Errorf("could not allocate a number after %d tries; too many pushes at once", maxRetries) } // readCounter returns the counter blob and value, and a repository with neither starts at zero. func readCounter(ctx context.Context, dir string) (blob string, value int, err error) { out, err := gitx.Run(ctx, dir, "rev-parse", "--verify", "--quiet", CounterRef) if err != nil { return "", 0, nil // no counter yet } blob = strings.TrimSpace(out) body, err := gitx.Run(ctx, dir, "cat-file", "blob", blob) if err != nil { // Refuse rather than restart the numbering, which threads already refer to. return "", 0, fmt.Errorf("%s is not readable: %w", CounterRef, err) } value, err = strconv.Atoi(strings.TrimSpace(body)) if err != nil { return "", 0, fmt.Errorf("%s does not hold a number: %q", CounterRef, strings.TrimSpace(body)) } return blob, value, nil } func Ref(n int) string { return "refs/proposals/" + strconv.Itoa(n) } // RevisionRef retains old tips outside refs/proposals, where a file and directory would collide. func RevisionRef(n, k int) string { return fmt.Sprintf("refs/revisions/%d/%d", n, k) } // CurrentRevision is the number the content on screen will take when the next force-push retains it. func CurrentRevision(dir string, n int) int { refs, err := gitx.ListRefs(dir, RevisionPrefix(n)) if err != nil { return 0 } // The highest kept plus one, because pruning drops the low numbers and a count would reuse them. high := 0 for name := range refs { k, err := strconv.Atoi(strings.TrimPrefix(name, RevisionPrefix(n)+"/")) if err == nil && k > high { high = k } } return high + 1 } // RevisionPrefix is where one proposal's retained tips live, as a ref prefix and not a glob. func RevisionPrefix(n int) string { return fmt.Sprintf("refs/revisions/%d", n) } // RevisionGlob matches every retained revision of proposal n. func RevisionGlob(n int) string { return fmt.Sprintf("refs/revisions/%d/*", n) } // NewRef is the magic name. It is reserved and never created. const NewRef = "refs/proposals/new" // Number reads the proposal number out of a ref name, or returns 0. func Number(ref string) int { rest, ok := strings.CutPrefix(ref, "refs/proposals/") if !ok { return 0 } // refs/proposals/47 and refs/proposals/47/rev/2 both belong to 47. if i := strings.Index(rest, "/"); i >= 0 { rest = rest[:i] } n, err := strconv.Atoi(rest) if err != nil || n <= 0 { return 0 } return n } // taken reports whether a number already names a thread or a proposal. func taken(ctx context.Context, dir string, n int) bool { for _, ref := range []string{Ref(n), "refs/notes/threads/" + strconv.Itoa(n)} { if _, err := gitx.Run(ctx, dir, "rev-parse", "--verify", "--quiet", ref); err == nil { return true } } return false }