Artifact 893c7cf7e29c62db8c3f28623164b27a6d0fdd6944dba3e6bc1e05c85636fc30:
- File
repo_checkout.go
— part of check-in
[9a2697c556]
at
2026-07-19 16:00:36
on branch trunk
— fix: split repository enumeration from ancestry walking (#45) (#54)
Repo.Timeline was an ancestry walk wearing an enumeration's name: it required a start rid, emitted that check-in, followed plink where isprim=1 to the primary parent, and repeated until the chain ended. It never touched the event table and never visited a second parent or a sibling branch head, so it silently omitted any check-in that wasn't a first-parent ancestor of the given start rid — on libfossil's own repository that was 577 of 3,761 check-ins, with no error and no warning. The limit option never bound because the (wrong) walk exhausted first.
Split into two operations under honest names:
- Repo.Timeline(TimelineOpts) enumerates the event table newest first, every kind by default or a single kind via Type. This matches canonical `fossil timeline`'s -t/--type default (a filter only applies when given and not 'a'). - Repo.Ancestry(LogOpts) keeps the original first-parent walk, unchanged in behavior, under a name that says what it does.
Breaking change, no deprecated shim (pre-1.0, clean break): callers of the old Timeline(LogOpts) need to move to Ancestry(LogOpts) if they want the walk, or to Timeline(TimelineOpts) if they want the enumeration fossil's own timeline command performs. The only in-tree callers were the `repo timeline` CLI command and the two examples; all three are migrated to Timeline(TimelineOpts). See CHANGELOG.md.
LogEntry gains a Kind field identifying which of event.type's six kinds (ci, e, f, g, t, w) an entry is; Parents is only populated for check-ins, since plink only relates check-in artifacts.
Timeline's pagination cursor orders by (mtime DESC, rid DESC) instead of canonical fossil's bare mtime DESC with no tie-break — a deliberate improvement, not an oversight, since fossil's web cursor combines that missing tie-break with a one-second timestamp slop and can repeat or skip rows at a page boundary. Getting the composite cursor to actually round-trip losslessly required fixing a real bug along the way: JulianToTime truncated instead of rounded when converting a julian day back to milliseconds, so TimeToJulian(JulianToTime(m)) was not always equal to m even for exact-millisecond inputs — enough to misplace a row at a page boundary under exactly the same-mtime conditions this issue's acceptance criteria test for. Fixed to round to nearest. Because the same class of drift also appears in the ncruces/WASM sqlite driver's own internal REAL-to-time.Time conversion (independent of this library's code), the cursor comparison also carries a small explicit tolerance rather than relying on bit-exact equality across drivers.
Both the ancestry walk's loop and the enumeration's row loop now carry an explicit, testable bound so a corrupt or cyclic plink table can't hang the process. (user: [email protected], size: 6658) [annotate] [blame] [check-ins using]
package libfossil import ( "fmt" "time" "github.com/danmestas/libfossil/internal/deck" "github.com/danmestas/libfossil/internal/fsltype" "github.com/danmestas/libfossil/internal/manifest" ) // CommitOpts configures a commit operation. type CommitOpts struct { // Files is the caller-supplied subset of the new commit's tree. When // ParentID is non-zero, Files is MERGED with the parent's tracked files: // any name in Files overrides the parent's entry, and any file tracked // at the parent but not in Files is carried forward into the new manifest. // This matches `fossil ci`'s full-tree semantics — supplying just the // changed files does not erase the rest of the tree. Files []FileToCommit Comment string User string Tags []TagSpec Time time.Time ParentID int64 // MergeParents lists additional parents for a merge commit. The resulting // manifest's P-card is [ParentID, MergeParents...]; ParentID is the primary // parent (branch tip being committed onto), and each MergeParent contributes // a secondary plink row (isprim=0). MergeParents []int64 Delta bool // PartialManifest, when true, skips the parent-file merge: the resulting // manifest contains only Files. This is the legacy pre-fix behavior and // effectively erases every file tracked at the parent that is not in // Files. Default false matches `fossil ci`. Set true only when the // caller intentionally wants omission-to-mean-deletion (e.g., tests for // the diff/deletion path); a dedicated deletion API may replace this // escape hatch later. PartialManifest bool } // FileToCommit describes a file to include in a commit. type FileToCommit struct { Name string Content []byte Perm string } // TagSpec describes a tag to attach to an artifact. type TagSpec struct { Name string Value string } // CheckoutOpts configures a checkout extraction. type CheckoutOpts struct { Dir string Force bool } // FileEntry describes a file in a manifest. type FileEntry struct { Name string UUID string Perm string } // StatusEntry describes a changed file in the working tree. type StatusEntry struct { Name string Change string } // Commit creates a new checkin manifest with the given files and returns // the RID (row ID) and UUID of the newly created artifact. When ParentID // is non-zero, files tracked at the parent but absent from opts.Files are // carried forward into the new manifest (full-tree semantics — see the // CommitOpts.Files doc). func (r *Repo) Commit(opts CommitOpts) (int64, string, error) { files := make([]manifest.File, len(opts.Files)) for i, f := range opts.Files { files[i] = manifest.File{ Name: f.Name, Content: f.Content, Perm: f.Perm, } } if opts.ParentID > 0 && !opts.PartialManifest { merged, err := manifest.MergeParentFiles(r.inner, fsltype.FslID(opts.ParentID), files) if err != nil { return 0, "", fmt.Errorf("libfossil: commit: %w", err) } files = merged } var tagCards []deck.TagCard for _, t := range opts.Tags { tagCards = append(tagCards, deck.TagCard{ UUID: "*", Type: deck.TagPropagating, Name: t.Name, Value: t.Value, }) } var mergeParents []fsltype.FslID if len(opts.MergeParents) > 0 { mergeParents = make([]fsltype.FslID, len(opts.MergeParents)) for i, mp := range opts.MergeParents { mergeParents[i] = fsltype.FslID(mp) } } copts := manifest.CheckinOpts{ Files: files, Comment: opts.Comment, User: opts.User, Parent: fsltype.FslID(opts.ParentID), MergeParents: mergeParents, Delta: opts.Delta, Time: opts.Time, Tags: tagCards, } rid, uuid, err := manifest.Checkin(r.inner, copts) if err != nil { return 0, "", fmt.Errorf("libfossil: commit: %w", err) } return int64(rid), uuid, nil } // Ancestry walks the primary-parent chain starting from opts.Start: it // emits that check-in, follows plink where isprim=1 to its parent, and // repeats until the chain ends or opts.Limit is reached. This only ever // sees first-parent ancestors of opts.Start — a merge's second parent, a // sibling branch head, or any check-in outside that one chain will never // appear. Use Timeline for a repository-wide view. func (r *Repo) Ancestry(opts LogOpts) ([]LogEntry, error) { entries, err := manifest.Log(r.inner, manifest.LogOpts{ Start: fsltype.FslID(opts.Start), Limit: opts.Limit, }) if err != nil { return nil, fmt.Errorf("libfossil: ancestry: %w", err) } result := make([]LogEntry, len(entries)) for i, e := range entries { result[i] = LogEntry{ RID: int64(e.RID), UUID: e.UUID, Comment: e.Comment, User: e.User, Time: e.Time, Kind: e.Kind, Parents: e.Parents, } } return result, nil } // Timeline enumerates the repository's events newest-first: every row of // the event table by default, or just the rows matching opts.Type when // set (canonical `fossil timeline`'s -t/--type default: no filter unless // one is given). Unlike Ancestry, this does not start from or require any // particular check-in — it is a repository-wide view, not a walk. // // Ordering is (mtime DESC, rid DESC) — a deliberate improvement over // canonical fossil, which orders by mtime DESC alone with no tie-break, // and paginates with a bare-timestamp web cursor carrying a one-second // slop, so rows at a page boundary there can repeat or be skipped. Do not // "fix" this back to canonical: it is intentional. To page through the // full result set, pass the last entry's Time and RID back as the next // call's Before and After. func (r *Repo) Timeline(opts TimelineOpts) ([]LogEntry, error) { entries, err := manifest.Timeline(r.inner, manifest.TimelineOpts{ Type: opts.Type, Before: opts.Before, After: fsltype.FslID(opts.After), Limit: opts.Limit, }) if err != nil { return nil, fmt.Errorf("libfossil: timeline: %w", err) } result := make([]LogEntry, len(entries)) for i, e := range entries { result[i] = LogEntry{ RID: int64(e.RID), UUID: e.UUID, Comment: e.Comment, User: e.User, Time: e.Time, Kind: e.Kind, Parents: e.Parents, } } return result, nil } // ListFiles returns the files in a manifest identified by blob row-id. func (r *Repo) ListFiles(rid int64) ([]FileEntry, error) { entries, err := manifest.ListFiles(r.inner, fsltype.FslID(rid)) if err != nil { return nil, fmt.Errorf("libfossil: list files: %w", err) } result := make([]FileEntry, len(entries)) for i, e := range entries { result[i] = FileEntry{ Name: e.Name, UUID: e.UUID, Perm: e.Perm, } } return result, nil }