package thread import ( "context" "fmt" "hash/fnv" "sort" "strconv" "strings" "sync" "time" "github.com/barerepo/server/internal/gitx" ) // maxRetries bounds the swap on the notes ref, where contention is two simultaneous comments. const maxRetries = 20 // stripes serialise this process's writers. A map keyed by repository and thread would only grow. var stripes [64]sync.Mutex // lockFor picks the mutex guarding one thread's ref. func lockFor(dir string, n int) *sync.Mutex { h := fnv.New32a() h.Write([]byte(dir + "/" + strconv.Itoa(n))) return &stripes[h.Sum32()%uint32(len(stripes))] } // Write swaps the note ref, so a losing writer reads again rather than dropping a comment. func Write(ctx context.Context, dir string, n int, message string, mutate func(t *Tree)) error { // One writer at a time here, so the swap below only ever loses to another process. mu := lockFor(dir, n) mu.Lock() defer mu.Unlock() for attempt := 0; attempt < maxRetries; attempt++ { old, tree, err := read(ctx, dir, n) if err != nil { return err } mutate(tree) treeSHA, err := tree.write(ctx, dir) if err != nil { return err } args := []string{"commit-tree", treeSHA, "-m", message} if old != "" { args = append(args, "-p", old) } commit, err := gitx.RunStdin(ctx, dir, "", args...) if err != nil { return err } commit = strings.TrimSpace(commit) if _, err := gitx.Run(ctx, dir, "update-ref", Ref(n), commit, old); err == nil { return nil } // Somebody wrote between the read and the write, so read again with their record in. } return fmt.Errorf("could not write thread %d after %d tries", n, maxRetries) } // Tree is one note tree: a meta blob and a note per annotated object. type Tree struct { Meta Meta Notes map[string]string // object sha -> note body order []string // the order paths were first seen, so trees are stable // blobs keeps each entry's hash, so an untouched note costs no git process to rewrite. blobs map[string]string dirty map[string]bool // metaBlob and metaWas let an unchanged meta blob be reused. metaBlob string metaWas string } // Append adds a comment to the note on object. func (t *Tree) Append(object string, c Comment) { body := t.Notes[object] if strings.TrimSpace(body) != "" { body = strings.TrimRight(body, "\n") + "\n" + recordSep + "\n" } else { body = "" } t.set(object, body+c.Render()) } func (t *Tree) set(path, body string) { if t.Notes == nil { t.Notes = map[string]string{} } if _, seen := t.Notes[path]; !seen { t.order = append(t.order, path) } t.Notes[path] = body if t.dirty == nil { t.dirty = map[string]bool{} } t.dirty[path] = true } // keep records the hash of an entry this write does not touch. func (t *Tree) keep(path, body, blob string) { if t.Notes == nil { t.Notes = map[string]string{} } if t.blobs == nil { t.blobs = map[string]string{} } if _, seen := t.Notes[path]; !seen { t.order = append(t.order, path) } t.Notes[path] = body t.blobs[path] = blob } // read loads the current note tree, or an empty one, in two processes whatever the note count. func read(ctx context.Context, dir string, n int) (commit string, t *Tree, err error) { t = &Tree{Meta: Meta{State: Open}} // A file read in a bare repository, falling back to git, which a worktree needs to be right. commit, err = gitx.ResolveRefOrAsk(ctx, dir, Ref(n)) if err != nil { return "", t, nil // no thread yet } // The tree of the commit this write will name as its parent, not of the ref, which can move. head, err := gitx.Batch(ctx, dir, []string{commit + "^{tree}"}) if err != nil { return "", nil, err } root := head[commit+"^{tree}"] if root == nil { return "", nil, fmt.Errorf("thread %d has no tree at %s", n, commit) } entries := gitx.TreeEntries(root.Body) // Sorted, because git answers a tree in name order and the entry order decides nothing else. paths := make([]string, 0, len(entries)) for path := range entries { paths = append(paths, path) } sort.Strings(paths) specs := make([]string, 0, len(paths)) for _, path := range paths { specs = append(specs, entries[path]) } blobs, err := gitx.Batch(ctx, dir, specs) if err != nil { return "", nil, err } for _, path := range paths { sha := entries[path] blob := blobs[sha] if blob == nil || blob.Type != "blob" { continue } if path == "meta" { t.Meta = ParseMeta(blob.Body) t.metaBlob = sha t.metaWas = blob.Body continue } t.keep(path, blob.Body, sha) } return commit, t, nil } // write builds the tree object. func (t *Tree) write(ctx context.Context, dir string) (string, error) { var entries strings.Builder add := func(path, body, known string) error { blob := known if blob == "" { out, err := gitx.RunStdin(ctx, dir, body, "hash-object", "-w", "--stdin") if err != nil { return err } blob = strings.TrimSpace(out) } fmt.Fprintf(&entries, "100644 blob %s\t%s\n", blob, path) return nil } // Only what this write changed is hashed again. meta := t.Meta.Render() known := "" if t.metaBlob != "" && meta == t.metaWas { known = t.metaBlob } if err := add("meta", meta, known); err != nil { return "", err } for _, path := range t.order { body := t.Notes[path] if strings.TrimSpace(body) == "" { continue } reuse := "" if !t.dirty[path] { reuse = t.blobs[path] } if err := add(path, body, reuse); err != nil { return "", err } } out, err := gitx.RunStdin(ctx, dir, entries.String(), "mktree") if err != nil { return "", err } return strings.TrimSpace(out), nil } // Create opens a thread, or returns an error if n already exists. func Create(ctx context.Context, dir string, n int, m Meta, object string, first Comment) error { if _, exists, err := ReadMeta(ctx, dir, n); err != nil { return err } else if exists { return fmt.Errorf("thread %d already exists", n) } msg := fmt.Sprintf("open thread %d", n) if m.Author != "" { msg += " by " + m.Author } return Write(ctx, dir, n, msg, func(t *Tree) { t.Meta = m if object != "" && strings.TrimSpace(first.Body) != "" { t.Append(object, first) } }) } // Reply appends a comment, authored by barerepo, because an account has no email to borrow. func Reply(ctx context.Context, dir string, n int, object string, c Comment) error { msg := fmt.Sprintf("reply on thread %d", n) if c.Author != "" { msg += " by " + c.Author } return Write(ctx, dir, n, msg, func(t *Tree) { t.Append(object, c) }) } // SetState moves a thread, where merging is a fact the server observed rather than caused. func SetState(ctx context.Context, dir string, n int, state State, mergedAt string) error { return Write(ctx, dir, n, fmt.Sprintf("thread %d is %s", n, state), func(t *Tree) { t.Meta.State = state if mergedAt != "" { t.Meta.Merged = mergedAt } }) } // NotesAt reads every note as one commit leaves them, so a push can be compared with what it replaced. func NotesAt(ctx context.Context, dir, commit string) map[string]string { out := map[string]string{} if commit == "" || strings.Trim(commit, "0") == "" { return out } spec := commit + "^{tree}" head, err := gitx.Batch(ctx, dir, []string{spec}) if err != nil || head[spec] == nil { return out } entries := gitx.TreeEntries(head[spec].Body) specs := make([]string, 0, len(entries)) for path, sha := range entries { if path != "meta" { specs = append(specs, sha) } } blobs, err := gitx.Batch(ctx, dir, specs) if err != nil { return out } for path, sha := range entries { if path == "meta" { continue } if blob := blobs[sha]; blob != nil && blob.Type == "blob" { out[path] = blob.Body } } return out } // Repair gives text appended by hand the author the push knew, since 35.5 writes no header. func Repair(ctx context.Context, dir string, n int, was map[string]string, who string, at time.Time) error { if who == "" { return nil } _, tree, err := read(ctx, dir, n) if err != nil || tree == nil { return err } if !needsRepair(tree, was) { return nil } return Write(ctx, dir, n, "attribute a pushed reply to "+who, func(t *Tree) { for _, object := range t.order { extra, ok := appended(was[object], t.Notes[object]) if !ok { continue } c := Comment{Author: who, Time: at, Body: strings.TrimSpace(extra)} t.set(object, rejoin(was[object], c)) } }) } // needsRepair asks the question Repair answers, so an ordinary push writes no commit at all. func needsRepair(t *Tree, was map[string]string) bool { for _, object := range t.order { if _, ok := appended(was[object], t.Notes[object]); ok { return true } } return false } // appended returns the text this push added, and false when it added none that needs an author. func OnlyAppends(ctx context.Context, dir, old, now string) bool { was := NotesAt(ctx, dir, old) if len(was) == 0 { return false } has := NotesAt(ctx, dir, now) for object, before := range was { after, ok := has[object] if !ok || !strings.HasPrefix(after, before) { return false } } return true } func appended(old, now string) (string, bool) { if !strings.HasPrefix(now, old) { // A rewrite is not an append, and barerepo does not know whose words the new ones replaced. return "", false } extra := now[len(old):] if strings.TrimSpace(extra) == "" { return "", false } // A record that already names its author was written by something that knows the format. if _, ok := parseComment(extra); ok { return "", false } return extra, true } // rejoin puts the repaired record back after what was there, separated the way every record is. func rejoin(old string, c Comment) string { if strings.TrimSpace(old) == "" { return c.Render() } return strings.TrimRight(old, "\n") + "\n" + recordSep + "\n" + c.Render() }