Artifact 5f67d258bf305f94399dbca26c75409ba8507e377d07b320ff418a9445152b3b:
- File
internal/manifest/log.go
— part of check-in
[ee3ccc7c67]
at
2026-10-22 17:21:12
on branch feat/horizon-cones
— feat(history): render a horizon boundary as the horizon
A parent the origin labelled below a structural horizon is the horizon itself (draft-fossil-horizon-00 5.4), but the history views showed it as a bare parent hash, and the ancestry walk tried to read an event for it.
LogEntry and TimelineEntry gain HorizonParents, the subset of Parents that carries a boundary label; Ancestry ends at such a parent without an error; repo timeline prints it as "parent: <hash> (horizon)". An absent parent that is not labelled is transit and renders as before. (user: [email protected], size: 4979) [annotate] [blame] [check-ins using] [more...]
package manifest import ( "database/sql" "fmt" "time" "github.com/danmestas/go-libfossil/db" "github.com/danmestas/go-libfossil/internal/content" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" "github.com/danmestas/go-libfossil/internal/repo" ) type LogOpts struct { Start libfossil.FslID Limit int } // LogEntry represents a single event as returned by Log (the ancestry // walk). It carries no pagination cursor: Log paginates by Start/Limit, // not by cursor, so there is nothing valid to put in one. See // TimelineEntry, Timeline's result type, for the cursor-carrying // counterpart. type LogEntry struct { RID libfossil.FslID UUID string Comment string User string Time time.Time Kind libfossil.EventKind Parents []string // HorizonParents is the subset of Parents the store's origin labelled // below its structural horizon (draft-fossil-horizon-00 §5.4): the // horizon, not a missing parent. HorizonParents []string } // maxAncestryDepth bounds the first-parent walk in Log so that a cyclic or // otherwise corrupt plink table cannot hang the process (TigerStyle: every // loop needs an explicit, defensible bound). It is a var rather than a // const so tests can shrink it to exercise the bound deterministically // without walking a million rows. No real fossil repository's primary-parent // chain approaches this depth. var maxAncestryDepth = 1_000_000 func Log(r *repo.Repo, opts LogOpts) ([]LogEntry, error) { if r == nil { panic("manifest.Log: r must not be nil") } if opts.Start <= 0 { return nil, fmt.Errorf("manifest.Log: invalid start rid %d", opts.Start) } var entries []LogEntry labels, err := content.BelowLabels(r.DB()) if err != nil { return nil, fmt.Errorf("manifest.Log: %w", err) } current := opts.Start for depth := 0; ; depth++ { if depth >= maxAncestryDepth { return nil, fmt.Errorf("manifest.Log: exceeded max ancestry depth %d starting at rid=%d (possible plink cycle)", maxAncestryDepth, opts.Start) } if opts.Limit > 0 && len(entries) >= opts.Limit { break } // event.user and event.comment are nullable in fossil's schema: the // U-card and C-card are optional on a check-in manifest (the only // required cards are "D" and "Z"). A NULL here is valid data, not // malformed input, so scan through sql.NullString and let its zero // value ("") stand in for "no value recorded" — the same substitution // fossil itself makes when reading these columns. var uuid string var user, comment sql.NullString var mtimeScanned any err = r.DB().QueryRow( "SELECT b.uuid, e.user, e.comment, e.mtime FROM blob b JOIN event e ON e.objid=b.rid WHERE b.rid=?", current, ).Scan(&uuid, &user, &comment, &mtimeScanned) if err != nil { return nil, fmt.Errorf("manifest.Log: rid=%d: %w", current, err) } // mtime is a julianday float. modernc returns float64; // ncruces returns time.Time for DATETIME/TIMESTAMP columns. Handle both. mtime, ok := db.ScanTextJulianDay(mtimeScanned) if !ok { return nil, fmt.Errorf("manifest.Log: rid=%d: unexpected mtime type %T", current, mtimeScanned) } parents := parentUUIDs(r, current) entries = append(entries, LogEntry{ RID: current, UUID: uuid, Comment: comment.String, User: user.String, Time: libfossil.JulianToTime(mtime), Kind: libfossil.EventKindCheckin, Parents: parents, HorizonParents: horizonParents(labels, parents), }) var parentRid int64 var parentUUID string if err := r.DB().QueryRow( "SELECT p.pid, b.uuid FROM plink p JOIN blob b ON b.rid=p.pid WHERE p.cid=? AND p.isprim=1", current, ).Scan(&parentRid, &parentUUID); err != nil { break } if labels[parentUUID] { // The horizon ends the walk; it is not a missing parent. break } current = libfossil.FslID(parentRid) } return entries, nil } // horizonParents is the members of parents that labels names as below the // horizon. func horizonParents(labels map[string]bool, parents []string) []string { var out []string for _, p := range parents { if labels[p] { out = append(out, p) } } return out } // parentUUIDs returns the UUIDs of cid's parents (primary parent first), // via the plink table. Best-effort: a query or scan failure yields a nil // (empty) result rather than aborting the caller's walk or enumeration — // matching the original manifest.Log behavior this was extracted from. // Shared by Log (ancestry walk) and Timeline (enumeration) so entry // construction is not duplicated between the two query paths. func parentUUIDs(r *repo.Repo, cid libfossil.FslID) []string { var parents []string rows, err := r.DB().Query( "SELECT b.uuid FROM plink p JOIN blob b ON b.rid=p.pid WHERE p.cid=? ORDER BY p.isprim DESC", cid, ) if err != nil { return parents } defer rows.Close() for rows.Next() { var puuid string if err := rows.Scan(&puuid); err != nil { continue } parents = append(parents, puuid) } return parents }