Artifact 62832e002ace9d7c2ff175b0f47d9c2ea694baf1f315dc11daafec7000555147:
- File
internal/sync/horizon_deepen.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: 5099) [annotate] [blame] [check-ins using] [more...]
package sync import ( "context" "database/sql" "errors" "fmt" "strings" "github.com/danmestas/go-libfossil/internal/cone" "github.com/danmestas/go-libfossil/internal/content" 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" ) // ErrNoDeepenPoint is returned by Deepen when no common ancestor of the // store's C and the target can be established, here or by the origin: no // ancestor of C reaches the target, so no deepen does. var ErrNoDeepenPoint = errors.New("horizon: no common ancestor of the horizon and the target to deepen to") // Deepen moves the store's horizon toward target, the full hash of a // check-in the store needs history down to (draft-fossil-horizon-00 §5.8), // and synchronizes at the new C' through peer. C' is an ancestor of C, so K // only grows: target itself when it is one, else a common ancestor of C and // target, found over the graph the store holds or asked of the origin // (§6.2.4). The rule, the depth and the path members stay. FetchCone does // the rest: a structural store discards the seed for C before it takes the // one for C', and a failed transfer puts both back. func Deepen(ctx context.Context, r *repo.Repo, peer Transport, target string, opts SyncOpts) (*SyncResult, error) { if ctx == nil || r == nil || peer == nil { panic("sync.Deepen: nil context, repo or peer") } target = strings.ToLower(target) if !hash.IsValidHash(target) { return nil, fmt.Errorf("horizon: deepen target %q is not a full artifact hash", target) } sc, err := content.StoreCone(r.DB()) if err != nil { return nil, err } h, ok := horizonOf(sc) if !ok { return nil, errors.New("horizon: the store has no horizon to deepen") } point, err := deepenPoint(ctx, r, peer, h.Hash(), target, opts) if err != nil { return nil, err } nh, err := cone.NewHorizon(point, h.Rule(), h.Depth()) if err != nil { return nil, err } next, err := cone.NewWithHorizon(sc.Include(), sc.Exclude(), nh) if err != nil { return nil, err } return FetchCone(ctx, r, peer, next, opts) } // deepenPoint is where a deepen that must reach target moves C (zit's // deepenPoint, src/cmd/sync.zig): a common ancestor of the two, which is // target itself when target is C's ancestor and C when target descends from // it. The store's own graph answers when P cards it holds join the two, the // boundary's included; otherwise the origin is asked. func deepenPoint(ctx context.Context, r *repo.Repo, peer Transport, c, target string, opts SyncOpts) (string, error) { if c == target { return c, nil } rc, okC, err := blobRID(r, c) if err != nil { return "", err } rt, okT, err := blobRID(r, target) if err != nil { return "", err } if okC && okT { base, err := merge.FindCommonAncestorWithin(ctx, r, rc, rt, mergeBaseNodes) if errors.Is(err, merge.ErrNoCommonAncestor) { // The pivot search starts from check-ins only; a boundary // target among C's own ancestors is its own deepen point. base, err = boundaryAncestor(r, rc, rt) } switch { case err == nil: var uuid string if err := r.DB().QueryRow("SELECT uuid FROM blob WHERE rid=?", base).Scan(&uuid); err != nil { return "", fmt.Errorf("horizon: deepen point: %w", err) } return uuid, nil case !errors.Is(err, merge.ErrNoCommonAncestor) && !errors.Is(err, merge.ErrBound): return "", err } } opts.Pull, opts.Push = true, false opts.MergeBaseOf = [2]string{c, target} res, err := Sync(ctx, r, peer, opts) if err != nil { return "", err } if !res.MergeBaseAnswered { return "", fmt.Errorf("%w: %s and %s; the origin did not answer", ErrNoDeepenPoint, c, target) } if res.MergeBase == "" { return "", fmt.Errorf("%w: %s and %s; the origin names none", ErrNoDeepenPoint, c, target) } return res.MergeBase, nil } // boundaryAncestor is target when the store holds no check-in for it and it // is among c's ancestors in the plink graph the store holds, and // merge.ErrNoCommonAncestor otherwise. func boundaryAncestor(r *repo.Repo, c, target libfossil.FslID) (libfossil.FslID, error) { var found bool err := r.DB().QueryRow(`WITH RECURSIVE anc(rid) AS ( SELECT ?1 UNION SELECT plink.pid FROM plink JOIN anc ON plink.cid=anc.rid LIMIT ?3) SELECT EXISTS(SELECT 1 FROM anc WHERE rid=?2) AND NOT EXISTS(SELECT 1 FROM event WHERE objid=?2 AND type='ci')`, c, target, mergeBaseNodes).Scan(&found) if err != nil { return 0, fmt.Errorf("horizon: deepen point: %w", err) } if !found { return 0, merge.ErrNoCommonAncestor } return target, nil } // blobRID is the rid of the artifact named uuid, held or phantom. func blobRID(r *repo.Repo, uuid string) (libfossil.FslID, bool, error) { var rid libfossil.FslID err := r.DB().QueryRow("SELECT rid FROM blob WHERE uuid=?", uuid).Scan(&rid) if errors.Is(err, sql.ErrNoRows) { return 0, false, nil } if err != nil { return 0, false, fmt.Errorf("horizon: deepen point: %w", err) } return rid, true, nil }