Artifact fdaf59773548443676677c37f27d9d4d6544253c118556b5df98f3d07fe0558b:
- File
required_content_merge.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: 3636) [annotate] [blame] [check-ins using] [more...]
package libfossil import ( "context" "database/sql" "errors" "fmt" "github.com/danmestas/go-libfossil/db" "github.com/danmestas/go-libfossil/internal/content" internalsync "github.com/danmestas/go-libfossil/internal/sync" ) // ErrBelowHorizon is matched by the error of an operation that needs history // below the store's structural horizon (draft-fossil-horizon-00 §5.5), such // as a merge whose base lies there. The message names the horizon and the // remedy, a deepen (Repo.DeepenHorizon). var ErrBelowHorizon = content.ErrBelowHorizon // MergeContext is Merge with the remedy of draft-fossil-horizon-00 §5.5. When // the merge base lies below the store's structural horizon, or the walk that // finds it crosses the horizon, and Peer is given, it asks the origin for // the base (§6.2.4), deepens the horizon to reach it (Repo.DeepenHorizon), // and merges once more over the base the origin named, which the store then // holds. Without a Peer, or when the merge does not cross the horizon, it is // Merge. A merge never runs against a base the store does not hold. func (c *Checkout) MergeContext( ctx context.Context, opts CheckoutMergeOpts, required RequiredContentOpts, ) (CheckoutMergeResult, error) { if err := required.validate(ctx); err != nil { return CheckoutMergeResult{}, err } res, crossing := c.Merge(opts) if !errors.Is(crossing, ErrBelowHorizon) || required.Peer == nil { return res, crossing } r := c.inner.Repo() _, current, err := c.Version() if err != nil { return CheckoutMergeResult{}, fmt.Errorf("libfossil: merge: %w", err) } var side string if err := r.DB().QueryRow("SELECT uuid FROM blob WHERE rid=?", opts.Version).Scan(&side); err != nil { return CheckoutMergeResult{}, fmt.Errorf("libfossil: merge: %w", err) } peer := &transportAdapter{pub: required.Peer} ask := required.internalSync() ask.MergeBaseOf = [2]string{current, side} answer, err := internalsync.Sync(ctx, r, peer, ask) if err != nil { return CheckoutMergeResult{}, fmt.Errorf("libfossil: merge: asking the origin for the merge base: %w", err) } if !answer.MergeBaseAnswered { return CheckoutMergeResult{}, fmt.Errorf("%w; the origin did not answer a merge-base query", crossing) } if answer.MergeBase == "" { return CheckoutMergeResult{}, fmt.Errorf("libfossil: merge: %s shares no history with %s, the origin says", side, current) } base, held, err := heldCheckin(r.DB(), answer.MergeBase) if err != nil { return CheckoutMergeResult{}, fmt.Errorf("libfossil: merge: the base %s: %w", answer.MergeBase, err) } if !held { if _, err := internalsync.Deepen(ctx, r, peer, answer.MergeBase, required.internalSync()); err != nil { return CheckoutMergeResult{}, fmt.Errorf("libfossil: merge: deepening to the base %s: %w", answer.MergeBase, err) } if base, _, err = heldCheckin(r.DB(), answer.MergeBase); err != nil { return CheckoutMergeResult{}, fmt.Errorf("libfossil: merge: the base %s: %w", answer.MergeBase, err) } } // The store still holds part of the graph, so its own walk may cross // the horizon again: the origin's base is the merge's, as --baseline. return c.merge(opts, base) } // heldCheckin is the rid of the artifact named uuid, 0 when the store has no // such artifact, and whether it holds it as a check-in. func heldCheckin(q db.Querier, uuid string) (int64, bool, error) { var rid int64 var held bool err := q.QueryRow(`SELECT rid, size>=0 AND EXISTS(SELECT 1 FROM event WHERE objid=rid AND type='ci') FROM blob WHERE uuid=?`, uuid).Scan(&rid, &held) if errors.Is(err, sql.ErrNoRows) { return 0, false, nil } return rid, held, err }