Artifact 43036ad210a9cba5e4c3e29a6f412570ff25ddf87a05826cd47104cd9eaea644:
- File
internal/sync/horizon_mergebase.go
— part of check-in
[a23c7b12a9]
at
2026-10-22 20:12:19
on branch feat/horizon-cones
— fix(merge): choose merge bases as fossil's pivot_find does
go-libfossil picked a merge base with a bidirectional breadth-first walk, the common ancestor nearest by edge count. fossil's merge uses pivot.c's pivot_find: a queue ordered by pending DESC, mtime DESC that expands the newest pending check-in first and returns the first one with a child queued from the other side, the most recent common ancestor by event time. The two differ on criss-cross merges and skewed histories, so a merge here could diff against another base than fossil's and write other bytes.
merge.FindPivot ports pivot_find in memory: (rid, side) rows with REPLACE re-pending, ties broken as a reverse scan of aqueue_idx1 yields them (rid, then side, descending), a missing event mtime counted as 0.0, ignoreMerges following primary links only, and the walk bound of the horizon merge-base query kept as ErrBound. FindCommonAncestor and FindCommonAncestorWithin now ask it.
Call sites: - Checkout.Merge takes fossil's pid: the merged-in version primary, the checkout's version and every vmerge id=0 merge the secondaries (merge.c 1076-1083), and gains Baseline, fossil's --baseline. The name pivot nid (pivot_find(1), merge.c 1089-1096) feeds only add_renames and the rename fill-in (merge.c 1192-1257); Merge does not follow renames, so it is not ported. Update and stash use the checkout version as their pivot and pick no base. - Repo.Merge (branch merge) makes the source branch tip the primary. - The origin's horizon-mergebase answer makes the merged-in check-in primary and declines to answer from a walk that reached history it does not hold. - Deepen's local deepen point keeps a boundary target among C's ancestors as its own point, since pivot_find seeds from check-ins only.
Horizon client rule (draft-fossil-horizon-00 §5.5, agreed for the draft): a common ancestor found in part of the graph need not be the one the whole graph gives, so a merge asks the origin whenever the walk reaches a check-in the store does not hold, not only when the base it finds is not held. MergeBaseCrossing takes that from Pivot.Unheld, and MergeContext merges over the origin's base as a baseline, deepening only when the store does not hold it.
Changed expectations: the horizon merge fixtures (hzStruct's y into feat2, structuralMergeSource, TestBranchMergeRefusesBaseBelowHorizon) fork f below the horizon, merge c2 into f's line and f into c2's: fossil picks c2, not f. On that shape fossil 2.28 answers fossil merge-base y x -> pivot=52d5923b8d (c2) with f=8ac24055 also a common ancestor. The fixtures now add a check-in on f's line newer than the horizon, which fossil does pick, to keep a base below the horizon, and the crossing tests check both the held-base walk that crosses and the base below. (user: [email protected], size: 4578) [annotate] [blame] [check-ins using] [more...]
package sync import ( "context" "database/sql" "errors" "fmt" "strings" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" "github.com/danmestas/go-libfossil/internal/hash" "github.com/danmestas/go-libfossil/internal/merge" "github.com/danmestas/go-libfossil/internal/repo" "github.com/danmestas/go-libfossil/internal/xfer" ) // horizonMergeBasePragma asks an origin for the merge base of two check-ins, // `pragma horizon-mergebase A B`, and carries its answer, `pragma // horizon-mergebase A B BASE`, BASE being - when the two share no ancestor // (draft-fossil-horizon-00 §6.2.4). const horizonMergeBasePragma = "horizon-mergebase" // noMergeBase is the answer naming no common ancestor. const noMergeBase = "-" // mergeBaseNodes bounds the check-ins one merge-base answer visits (§8: the // bound of the origin's other graph walks, horizon.HeldSet's among them). const mergeBaseNodes = 1 << 20 // mergeBaseQuery reads a merge-base query: two well-formed hashes, or ok // false, which leaves the pragma ignored like any it does not know. func mergeBaseQuery(p *xfer.PragmaCard) ([2]string, bool) { if len(p.Values) != 2 || !hash.IsValidHash(p.Values[0]) || !hash.IsValidHash(p.Values[1]) { return [2]string{}, false } return [2]string{p.Values[0], p.Values[1]}, true } // answerMergeBase appends the answer to the request's merge-base query, if // it carried one this origin can answer. It answers in a pull's reply only, // where zit answers it, so the requester has read access to the history the // base is drawn from. func (h *handler) answerMergeBase(ctx context.Context) error { q := h.mergeBaseQuery if q == [2]string{} || !h.pullOK { return nil } base, ok, err := originMergeBase(ctx, h.repo, q[0], q[1], mergeBaseNodes) if errors.Is(err, merge.ErrBound) { return nil // unanswered: the client reads it as "deepen by hand" } if err != nil || !ok { return err } h.resp = append(h.resp, &xfer.PragmaCard{Name: horizonMergeBasePragma, Values: []string{q[0], q[1], base}}) return nil } // originMergeBase is the merge base of the check-ins named a and b, the // pivot fossil's merge of b into a checkout of a finds (merge.FindPivot), // bounded by limit: the base's hash, or noMergeBase when they share no // ancestor. ok is false when either is not a check-in the repository holds, // or the walk reaches one it does not hold: a store answers only from // history it holds. func originMergeBase(ctx context.Context, r *repo.Repo, a, b string, limit int) (string, bool, error) { if ctx == nil || r == nil { panic("sync.originMergeBase: nil context or repo") } ra, okA, err := heldCheckin(r, a) if err != nil || !okA { return "", false, err } rb, okB, err := heldCheckin(r, b) if err != nil || !okB { return "", false, err } base := ra if ra != rb { p, err := merge.FindPivot(ctx, r, merge.PivotQuery{Primary: rb, Secondaries: []libfossil.FslID{ra}, Limit: limit}) if p.Unheld { return "", false, nil } if errors.Is(err, merge.ErrNoCommonAncestor) { return noMergeBase, true, nil } if err != nil { return "", false, err } base = p.RID } var uuid string err = r.DB().QueryRow(`SELECT uuid FROM blob WHERE rid=? AND size>=0 AND EXISTS(SELECT 1 FROM event WHERE objid=blob.rid AND type='ci')`, base).Scan(&uuid) if errors.Is(err, sql.ErrNoRows) { return "", false, nil } if err != nil { return "", false, fmt.Errorf("sync: merge base: %w", err) } return uuid, true, nil } // heldCheckin resolves a full hash to a check-in the repository holds. func heldCheckin(r *repo.Repo, uuid string) (libfossil.FslID, bool, error) { var rid libfossil.FslID err := r.DB().QueryRow(`SELECT rid FROM blob WHERE uuid=? AND size>=0 AND EXISTS(SELECT 1 FROM event WHERE objid=blob.rid AND type='ci')`, strings.ToLower(uuid)).Scan(&rid) if errors.Is(err, sql.ErrNoRows) { return 0, false, nil } if err != nil { return 0, false, fmt.Errorf("sync: merge base: %w", err) } return rid, true, nil } // mergeBaseAnswer reads an answer to the query asked: the base's hash, or "" // when the origin names no common ancestor. ok is false for any other pragma // of the name, which is ignored. func mergeBaseAnswer(p *xfer.PragmaCard, asked [2]string) (string, bool) { if len(p.Values) != 3 || !strings.EqualFold(p.Values[0], asked[0]) || !strings.EqualFold(p.Values[1], asked[1]) { return "", false } if p.Values[2] == noMergeBase { return "", true } if !hash.IsValidHash(strings.ToLower(p.Values[2])) { return "", false } return strings.ToLower(p.Values[2]), true }