Artifact 50ee46b392001ee88d8381980ae7be98abd6f990f50c88b90a303a8b253a6018:
- File
repo_history.go
— part of check-in
[168e20fc91]
at
2026-07-19 16:30:58
on branch trunk
— fix: make Timeline pagination cursor an exact total order (#62)
* fix: make Timeline pagination cursor an exact total order
#54 (merged as 95cbbb7) split Repo.Timeline into a repository-wide enumeration and Repo.Ancestry, the original first-parent walk under an honest name. Its pagination cursor predicate used a tolerance window around the caller-supplied Before/After pair to work around a lossy julian<->time.Time round trip:
mtime < (C - eps) OR (mtime <= (C + eps) AND rid < After)
This reintroduces exactly the defect the composite cursor existed to prevent, and makes it worse: rows within eps of the boundary that are not real ties get filtered by rid instead of tie-broken by it, which can skip a row, duplicate another, or fail to advance the cursor at all — unlike canonical fossil's own bare-timestamp slop, which repeats rows but still advances. A pagination test using only exact-tie and one-hour-apart fixtures could not exercise the 0 < delta < eps range where the defect lives, so it stayed green.
Fix: make the predicate an exact total order, with rid as a true tie-break only at exact mtime equality:
mtime < C OR (mtime = C AND rid < After)
and stop deriving C from a time.Time altogether. TimelineOpts.After is now an opaque Cursor (internal/fsltype.Cursor, re-exported as libfossil.Cursor): obtained only from a LogEntry the library already produced, carrying the exact float64 mtime read off that row rather than one reconstructed from a rounded timestamp. A cursor built from parts can silently drift from the row it claims to follow; a cursor that can only ever be handed back cannot.
That exactness depends on the row's scanned mtime being faithful to begin with, which surfaced a second bug: under the ncruces/WASM sqlite driver, event.mtime scans as a time.Time via the driver's own internal REAL conversion, carrying ~13us of noise that db.ScanJulianDay's time.Time branch then truncated (via UnixMilli()) into a full missed millisecond — independent of and in addition to the JulianToTime truncation bug #54 already fixed on the read-back side. Fixed db.timeToJulian to round to the nearest millisecond, the same class of fix, at the point where the driver-specific noise actually enters.
Also tightened the regression coverage: - Pagination tests now use a fixed loop bound of exactly ceil(n/pageSize) pages rather than an open "while non-empty" loop, so a non-advancing cursor fails the test immediately instead of hanging it. - Added a sub-millisecond-boundary fixture (events 3ms apart, well inside the old epsilon window) with rid order deliberately decoupled from mtime order — a monotonic fixture cannot expose this class of bug, since rid and mtime need not move together in general (backdated commits, imports). - TestJulianRoundTrip now asserts exact equality for values originating from TimeToJulian instead of a *1ms tolerance, which would have passed under both the old truncating JulianToTime and the current rounding one and so was not a regression guard for that fix.
No change to Ancestry, event-type filtering, NULL tolerance, or either loop bound; all were already correct.
* fix: assert pagination termination; mark Cursor change breaking
Both pagination tests (the pre-existing ones and the sub-millisecond regression) ran exactly ceil(n/pageSize) pages and asserted each event appeared once, but neither ever called Timeline one more time to confirm the next page comes back empty. The headline symptom of the epsilon-cursor defect these tests exist to catch was non-termination — a caller paginating until empty never stops — and that specific failure mode was going unguarded: a cursor that enumerates every row correctly but never signals exhaustion would have passed all four tests as they stood.
Added one more Timeline call after the last expected page in all four tests, asserting it returns zero entries. Verified this is not vacuous: reverted the tie-break to <= (non-strict) and confirmed the existing per-event count checks catch it; separately, made Timeline phantom-return one row only on an already-exhausted call (isolated from the real per-page counts, which stayed correct in that experiment) and confirmed only the new final-page assertion fails — proving it exercises a real, non-redundant path, not one already covered by the count checks. Both experiments reverted before commit.
Also corrected the CHANGELOG bullet describing the Cursor redesign: it described the new Cursor type but never stated that TimelineOpts.Before (time.Time) and TimelineOpts.After (FslID) were deleted outright, not deprecated. A reader diffing releases would have read it as an addition rather than a second compile-breaking change on top of #54. (user: [email protected], size: 12113) [annotate] [blame] [check-ins using]
package libfossil import ( "database/sql" "errors" "fmt" "sort" "time" "github.com/danmestas/libfossil/internal/annotate" "github.com/danmestas/libfossil/internal/content" "github.com/danmestas/libfossil/internal/diff" "github.com/danmestas/libfossil/internal/fsltype" "github.com/danmestas/libfossil/internal/manifest" ) // ErrFileNotFound is returned by ReadFile when the requested filePath is // not tracked in the given checkin. Callers can match with errors.Is. var ErrFileNotFound = errors.New("libfossil: file not found in checkin") // LogOpts configures an Ancestry query: a first-parent walk starting from // a specific check-in. type LogOpts struct { Start int64 Limit int } // TimelineOpts configures a Timeline query: a repository-wide enumeration // of the event table. type TimelineOpts struct { // Type restricts the enumeration to a single event kind. The zero // value means "all kinds" — the canonical `fossil timeline` default. Type EventKind // After, when valid, resumes enumeration immediately following this // cursor — the next page after whatever LogEntry produced it. The zero // Cursor means "start from the newest event". See Repo.Timeline's doc // comment for the pagination contract this forms, and Cursor's doc // comment for why it must come from a LogEntry rather than be built by // hand. After Cursor // Limit caps the number of entries returned. Zero or negative means // unbounded. Limit int } // LogEntry represents a single event in the timeline or ancestry chain. // Parents is populated for Kind == EventKindCheckin and empty for every // other kind — plink, which Parents is derived from, only relates // check-in artifacts, so that is correct, not a gap. Cursor is populated // on entries returned by Timeline and is the token to pass back as the // next page's TimelineOpts.After; it is the zero value (unusable) on // entries returned by Ancestry, which paginates by Start/Limit instead. type LogEntry struct { RID int64 UUID string Comment string User string Time time.Time Kind EventKind Parents []string Cursor Cursor } // DiffEntry describes a unified diff for a single file. type DiffEntry struct { Name string Unified string } // AnnotatedLine is a single line of blame/annotate output. type AnnotatedLine struct { Text string UUID string User string Date time.Time } // AnnotateOpts configures an annotate operation. type AnnotateOpts struct { FilePath string StartRID int64 } // StatusOpts configures a working-tree status query. type StatusOpts struct { Dir string } // BisectSession holds state for a binary-search bisect operation. type BisectSession struct { inner interface{} } // Annotate attributes each line of a file to the commit that last changed it. func (r *Repo) Annotate(opts AnnotateOpts) ([]AnnotatedLine, error) { lines, err := annotate.Annotate(r.inner, annotate.Options{ FilePath: opts.FilePath, StartRID: fsltype.FslID(opts.StartRID), }) if err != nil { return nil, fmt.Errorf("libfossil: annotate: %w", err) } result := make([]AnnotatedLine, len(lines)) for i, l := range lines { result[i] = AnnotatedLine{ Text: l.Text, UUID: l.Version.UUID, User: l.Version.User, Date: l.Version.Date, } } return result, nil } // Diff returns the unified diff(s) between ridA and ridB. // // When filePath is non-empty, returns 0 or 1 entries for that single file: // the file is treated as empty bytes on any side where it is absent, so // additions and deletions render as pure insert/delete hunks. An empty // slice is returned when both sides are byte-identical. // // When filePath is empty, returns one entry per file that changed between // the two checkins (the union of files across both sides where the content // UUID differs or the file exists on only one side). Entries are sorted by // Name for deterministic ordering. An empty slice is returned when the two // checkins have identical file sets and content. // // Whole-checkin enumeration is currently name-keyed: a rename with no // content change surfaces as a delete of the old name plus an add of the // new name, and permission-only changes are not reflected. Proper rename // and perm-change detection (via the underlying mlink table) is tracked as // a follow-up; per-file diff behaviour for an explicitly named file is // unchanged. func (r *Repo) Diff(ridA, ridB int64, filePath string) ([]DiffEntry, error) { if filePath == "" { return r.diffWholeCheckin(ridA, ridB) } return r.diffSingleFile(ridA, ridB, filePath) } // diffSingleFile produces the diff for a single named path between two // checkins. Extracted from Diff so the whole-checkin path can reuse the // same per-file rendering for each changed file in the union set. func (r *Repo) diffSingleFile(ridA, ridB int64, filePath string) ([]DiffEntry, error) { a, err := blobAt(r, ridA, filePath) if err != nil { return nil, fmt.Errorf("libfossil: diff: checkin %d: %w", ridA, err) } b, err := blobAt(r, ridB, filePath) if err != nil { return nil, fmt.Errorf("libfossil: diff: checkin %d: %w", ridB, err) } unified := diff.Unified(a, b, diff.Options{ ContextLines: 3, SrcName: "a/" + filePath, DstName: "b/" + filePath, }) if unified == "" { return []DiffEntry{}, nil } return []DiffEntry{{Name: filePath, Unified: unified}}, nil } // diffWholeCheckin enumerates the union of files across the two checkins // and emits one DiffEntry per file whose content UUID differs or that // exists on only one side. Results are sorted by Name. func (r *Repo) diffWholeCheckin(ridA, ridB int64) ([]DiffEntry, error) { filesA, err := manifest.ListFiles(r.inner, fsltype.FslID(ridA)) if err != nil { return nil, fmt.Errorf("libfossil: diff: checkins %d..%d: list files at %d: %w", ridA, ridB, ridA, err) } filesB, err := manifest.ListFiles(r.inner, fsltype.FslID(ridB)) if err != nil { return nil, fmt.Errorf("libfossil: diff: checkins %d..%d: list files at %d: %w", ridA, ridB, ridB, err) } uuidA := make(map[string]string, len(filesA)) for _, f := range filesA { uuidA[f.Name] = f.UUID } uuidB := make(map[string]string, len(filesB)) for _, f := range filesB { uuidB[f.Name] = f.UUID } changed := make(map[string]struct{}, len(uuidA)+len(uuidB)) for name, ua := range uuidA { if uuidB[name] != ua { changed[name] = struct{}{} } } for name, ub := range uuidB { if uuidA[name] != ub { changed[name] = struct{}{} } } if len(changed) == 0 { return []DiffEntry{}, nil } names := make([]string, 0, len(changed)) for name := range changed { names = append(names, name) } sort.Strings(names) out := make([]DiffEntry, 0, len(names)) for _, name := range names { entries, err := r.diffSingleFile(ridA, ridB, name) if err != nil { return nil, err } // diffSingleFile returns an empty slice when both sides are byte- // identical. Identical bytes under a changed UUID are not expected // (UUID is content-addressed), but skip the entry rather than emit // an empty diff if it does happen. out = append(out, entries...) } return out, nil } // ReadFile returns the bytes of filePath as they existed in checkin rid. // Returns ErrFileNotFound (wrapped) if the file is not tracked in that // checkin. A file that exists but is empty returns ([]byte{}, nil). func (r *Repo) ReadFile(rid int64, filePath string) ([]byte, error) { if filePath == "" { return nil, fmt.Errorf("libfossil: read file: filePath is required") } data, err := blobAt(r, rid, filePath) if err != nil { return nil, fmt.Errorf("libfossil: read file: checkin %d: %w", rid, err) } if data == nil { return nil, fmt.Errorf("libfossil: read file: %q in checkin %d: %w", filePath, rid, ErrFileNotFound) } return data, nil } // ResolveVersion resolves a symbolic version name to a repository artifact RID. // // Resolution order: // 1. "" or "tip" — newest checkin by mtime from the event table. // 2. "trunk" — tip of the trunk branch via tagxref/tag; falls back to "tip" // if the repository has no trunk tag. // 3. Named branch — tag lookup for "sym-<name>" in tagxref/tag (e.g. "feature-x" // resolves via sym-feature-x). // 4. Full UUID (≥40 chars) — exact match against blob.uuid. // 5. UUID prefix (4–39 chars) — unique-prefix match; returns ErrAmbiguousVersion // if more than one artifact matches. // // An empty result or no match returns ErrVersionNotFound (wrapped). // An ambiguous prefix returns ErrAmbiguousVersion (wrapped). func (r *Repo) ResolveVersion(name string) (int64, error) { db := r.inner.DB() switch name { case "", "tip": var rid int64 err := db.QueryRow( "SELECT objid FROM event WHERE type='ci' ORDER BY mtime DESC LIMIT 1", ).Scan(&rid) if errors.Is(err, sql.ErrNoRows) { return 0, fmt.Errorf("libfossil: resolve version %q: %w", name, ErrVersionNotFound) } if err != nil { return 0, fmt.Errorf("libfossil: resolve version %q: %w", name, err) } return rid, nil case "trunk": var rid int64 err := db.QueryRow(` SELECT tagxref.rid FROM tagxref JOIN tag ON tag.tagid = tagxref.tagid WHERE tag.tagname = 'sym-trunk' AND tagxref.tagtype > 0 ORDER BY tagxref.mtime DESC LIMIT 1`, ).Scan(&rid) if err == nil { return rid, nil } if !errors.Is(err, sql.ErrNoRows) { return 0, fmt.Errorf("libfossil: resolve version %q: %w", name, err) } // No trunk tag — fall back to tip. return r.ResolveVersion("tip") default: // First try as a named branch ("sym-<name>"). var rid int64 err := db.QueryRow(` SELECT tagxref.rid FROM tagxref JOIN tag ON tag.tagid = tagxref.tagid WHERE tag.tagname = ? AND tagxref.tagtype > 0 ORDER BY tagxref.mtime DESC LIMIT 1`, "sym-"+name, ).Scan(&rid) if err == nil { return rid, nil } if !errors.Is(err, sql.ErrNoRows) { return 0, fmt.Errorf("libfossil: resolve version %q: %w", name, err) } // Try as a UUID or UUID prefix. rows, err := db.Query( "SELECT rid FROM blob WHERE uuid LIKE ?", name+"%", ) if err != nil { return 0, fmt.Errorf("libfossil: resolve version %q: %w", name, err) } defer rows.Close() var matches []int64 for rows.Next() { var id int64 if err := rows.Scan(&id); err != nil { return 0, fmt.Errorf("libfossil: resolve version %q: %w", name, err) } matches = append(matches, id) } if err := rows.Err(); err != nil { return 0, fmt.Errorf("libfossil: resolve version %q: %w", name, err) } switch len(matches) { case 0: return 0, fmt.Errorf("libfossil: resolve version %q: %w", name, ErrVersionNotFound) case 1: return matches[0], nil default: return 0, fmt.Errorf("libfossil: resolve version %q matches %d artifacts: %w", name, len(matches), ErrAmbiguousVersion) } } } // ReadFileAt reads filePath from the checkin identified by a symbolic version // name (e.g. "tip", "trunk", a branch name, a UUID, or a UUID prefix). // It calls ResolveVersion to obtain the RID, then delegates to ReadFile. // Use ReadFile directly when you already have a numeric RID. func (r *Repo) ReadFileAt(version string, filePath string) ([]byte, error) { rid, err := r.ResolveVersion(version) if err != nil { return nil, fmt.Errorf("libfossil: read file at %q: %w", version, err) } return r.ReadFile(rid, filePath) } // blobAt returns the bytes of filePath as they exist in the given checkin. // A checkin that does not contain filePath returns (nil, nil); callers treat // that as "empty side" for diff purposes. Errors surface only for real I/O // or consistency problems (unknown RID, missing blob for a listed UUID). func blobAt(r *Repo, checkinRID int64, filePath string) ([]byte, error) { files, err := manifest.ListFiles(r.inner, fsltype.FslID(checkinRID)) if err != nil { return nil, err } for _, f := range files { if f.Name != filePath { continue } rid, ok := content.AvailableByUUID(r.DB(), f.UUID) if !ok { return nil, fmt.Errorf("blob not found for uuid %s", f.UUID) } return content.Expand(r.DB(), rid) } return nil, nil }