Artifact d2cd5418129e2faad5cce9f984eb2e92922df83df007ef4801a9570595951a20:
- File
internal/tag/tag.go
— part of check-in
[d810a8118f]
at
2026-10-18 20:01:53
on branch main
— fix: handle time exactly as fossil does (#442)
* fix(manifest): round a caller's check-in and tag time as fossil does
Fossil reads a --date-override through SQLite's strftime, which rounds the seconds to the millisecond (holding a fraction past .999 in its second) rather than truncating them. A caller time finer than a millisecond now gets the D card fossil would write for it; the default clock time is still truncated, as fossil reads its clock.
fsltype.CardTime and CardTimeNow are the one place both rules live, and the checkout commit path and branch creation take their default time through them.
* fix(repo): stamp config and user rows in seconds since 1970
Fossil writes config.mtime and user.mtime with its now() SQL function, time(0). SetConfig, SetHashPolicy and CreateUser wrote julianday('now') instead, so fossil read those rows as dating from 1970 when comparing times in a configuration merge, and SetCaps and SetPassword left the user's mtime alone where fossil's user command restamps it.
* fix(auth): read and write user.cexpire as a Julian day
Fossil keeps user.cexpire as a Julian day (src/login.c). A sync login parsed it as RFC 3339 or 'YYYY-MM-DD HH:MM:SS' text, so any value fossil had written failed to parse and the login was refused as expired. The expiry is now read through julianday(), which takes both forms, and repo invite --ttl writes a Julian day; its write error is now checked.
* fix(checkout): set file times as fossil's --setmtime does
Fossil's --setmtime gives each unedited file the time of the newest check-in, among the version and its primary ancestors, that recorded the file's content, truncated to whole seconds (mtime_of_manifest_file), and it does so for every unedited file of the version once the checkout is up to date (vfile_check_signature with CKSIG_SETMTIME). SetMTime gave every written file the target check-in's time to the millisecond, and an update left the files it did not write alone.
* fix(stash): keep and show stash and bisect times as fossil does
Fossil stores stash.ctime as julianday('now') and lists it with datetime(ctime); go-libfossil stored CURRENT_TIMESTAMP text, which the two drivers returned in different forms, and showed a fossil-written time as a bare number. bisect's list now shows datetime(event.mtime), as fossil's bisect ls does, instead of the Julian day.
* test(verify): fossil rebuild keeps the times go-libfossil stores
A repository go-libfossil wrote, with check-ins at caller and clock times, a tag and a branch, is rebuilt by the fossil binary, and every check-in event, plink and tagxref time must come back unchanged.
* test(sync): a fossil clone and push agree on every synced time
The fossil binary clones a go-libfossil server, unversioned files included, commits at a time finer than a millisecond and pushes back. Each side derives check-in event, plink and tagxref times from the artifacts itself, and the unversioned time travels on the wire; both repositories must then hold the same values.
* fix(resolve): bound NAME:TIMESTAMP with julianday() as fossil does
Fossil resolves BRANCH:TIMESTAMP with event.mtime<=julianday(BOUND). The bound was computed in the text form event rows hold, so on a millisecond where that form is larger than julianday()'s, a check-in at the bound itself resolved here and not in fossil.
* fix(manifest): round a caller's card time with integer arithmetic
CardTime followed SQLite's printing of the seconds as a double, which is not a well-defined rounding: at exact half-millisecond ties fossil 2.28's SQLite gives .124 for .1245 but .020 for .0195, and other SQLite versions differ again. A caller time is now rounded to the nearest millisecond on its nanoseconds, ties up, held at .999 as fossil holds it. The fossil binary test compares every non-tie value, across second, minute, day and year ends; ties are asserted on their own.
* fix(auth): ignore user.cexpire at sync login, as fossil does
user.cexpire is the expiry of fossil's web-login cookie: fossil sets it at web login, writes 0 at logout, and its sync login (check_login) never reads it. Refusing a sync login on it locked out anyone who had logged into or out of fossil's web UI, or whose cookie had lapsed.
The invite TTL that relied on it now lives in a config row of its own, invite-expire:LOGIN, as a UTC instant checked at sync login; repo invite no longer writes cexpire, and the expiry is no longer written in local time and read as UTC. Deleting a user drops its invite expiry.
Tracked in #441.
* fix(repo): convert Julian config and user mtimes on open
Repositories go-libfossil wrote before config and user rows were stamped in seconds since 1970 still hold Julian days there, which fossil reads as January 1970. Open converts those rows to the seconds they stand for. It checks first and writes only when a row needs it, so it converts once and takes no write lock on an up-to-date repository.
* fix(checkout): set a file no check-in recorded to 1970, as fossil does
With no mlink row for a file's artifact in the version's ancestry, fossil's max(event.mtime) is NULL, which mtime_of_manifest_file reads as 0, so --setmtime sets the file to 1970. SetMTime skipped the file.
* fix(stash): list stashes by creation time, as fossil's stash ls does
fossil stash ls orders by ctime, oldest first. List ordered by stash id, newest first; it now orders by ctime with the id breaking a tie.
* fix(auth): clear a login's invite expiry with the user, atomically
DeleteUser removed the user and its invite expiry in two statements; a failure between them left the user's expiry behind or the user without it. Both now go in one transaction. A user fossil deleted leaves its invite-expire row behind, so CreateUser clears it in the same transaction as the insert; repo invite sets its own expiry afterwards.
The on-open conversion of Julian config and user mtimes is now best-effort: while another connection holds the write lock, Open skips it and a later open converts the rows, instead of failing. (user: [email protected], size: 15372) [annotate] [blame] [check-ins using] [more...]
package tag import ( "database/sql" "errors" "fmt" "time" "github.com/danmestas/go-libfossil/db" "github.com/danmestas/go-libfossil/internal/blob" "github.com/danmestas/go-libfossil/internal/deck" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" "github.com/danmestas/go-libfossil/internal/leaf" "github.com/danmestas/go-libfossil/internal/repo" ) const ( TagCancel = 0 TagSingleton = 1 TagPropagating = 2 ) // TagOpts describes a tag operation on a target artifact. type TagOpts struct { TargetRID libfossil.FslID TagName string TagType int // TagCancel, TagSingleton, or TagPropagating Value string User string Time time.Time } // ApplyOpts describes a tag application from an existing control artifact. type ApplyOpts struct { TargetRID libfossil.FslID // Artifact to tag. // SrcRID is the artifact whose T card introduced this tag: the control // artifact, or the check-in carrying an inline card. tagxref keeps 0 for // inherited rows only, so a date tag needs it positive to set a time. SrcRID libfossil.FslID TagName string TagType int // TagCancel, TagSingleton, or TagPropagating. Value string MTime float64 // Julian day. // Leaves is the batch's leaf-recheck schedule. A branch tag, and the // propagation walk it starts, schedule the check-ins whose branch row // they write (draft-fossil-repo-state-01 Section 7.3); the caller applies // the schedule before its batch ends. ApplyTagWithTx requires it for a // branch tag; ApplyTag supplies its own when it is nil. Leaves *leaf.Pending } // AddTag creates a control artifact that adds or cancels a tag on a target checkin. // It stores the artifact as a blob, ensures the tag name exists in the tag table, // and inserts/replaces a row in the tagxref table. func AddTag(r *repo.Repo, opts TagOpts) (libfossil.FslID, error) { if r == nil { panic("tag.AddTag: r must not be nil") } if opts.TagName == "" { panic("tag.AddTag: opts.TagName must not be empty") } if opts.TargetRID <= 0 { panic("tag.AddTag: opts.TargetRID must be positive") } if opts.User == "" { panic("tag.AddTag: opts.User must not be empty") } // The D card holds the time as fossil writes it: to the millisecond, a // caller's time rounded as fossil rounds a --date-override. Finer digits // are legal (ยง4.6.4) but zit cannot read them. if opts.Time.IsZero() { opts.Time = libfossil.CardTimeNow(time.Now()) } else { opts.Time = libfossil.CardTime(opts.Time) } var controlRid libfossil.FslID err := r.WithTx(func(tx *db.Tx) error { d, err := controlDeck(tx, opts) if err != nil { return err } // Marshal and store as blob manifestBytes, err := d.Marshal() if err != nil { return fmt.Errorf("marshal control artifact: %w", err) } rid, _, err := blob.Store(tx, manifestBytes) if err != nil { return fmt.Errorf("store control artifact: %w", err) } controlRid = rid // Derive the tag the way crosslinking the control artifact would. A // superseded tag leaves tagxref alone, but the control artifact is // still stored and queued for sync. The leaf rechecks a branch tag // schedules are applied before this transaction commits, as fossil // applies them at the end of its own (src/db.c, leaf_do_pending_checks). leaves := leaf.NewPending() if err := ApplyTagWithTx(tx, ApplyOpts{ TargetRID: opts.TargetRID, SrcRID: controlRid, TagName: opts.TagName, TagType: opts.TagType, Value: opts.Value, MTime: libfossil.TimeToJulian(opts.Time), Leaves: leaves, }); err != nil { return err } if err := leaves.Apply(tx); err != nil { return err } // Mark control artifact as unsent so sync pushes it (unclustered is handled by blob.Store). if _, err := tx.Exec("INSERT OR IGNORE INTO unsent(rid) VALUES(?)", controlRid); err != nil { return fmt.Errorf("tag.AddTag: unsent: %w", err) } return nil }) if err != nil { return 0, fmt.Errorf("tag.AddTag: %w", err) } return controlRid, nil } // controlDeck builds the control artifact AddTag stores: one T card naming // the target by its hash. func controlDeck(q db.Querier, opts TagOpts) (*deck.Deck, error) { var targetUUID string const lookup = "SELECT uuid FROM blob WHERE rid=?" if err := q.QueryRow(lookup, opts.TargetRID).Scan(&targetUUID); err != nil { return nil, fmt.Errorf("target uuid lookup: %w", err) } // Map our integer tag type to deck.TagType byte var deckTagType deck.TagType switch opts.TagType { case TagCancel: deckTagType = deck.TagCancel case TagSingleton: deckTagType = deck.TagSingleton case TagPropagating: deckTagType = deck.TagPropagating default: return nil, fmt.Errorf("invalid tag type: %d", opts.TagType) } return &deck.Deck{ Type: deck.Control, D: opts.Time, T: []deck.TagCard{{ Type: deckTagType, Name: opts.TagName, UUID: targetUUID, Value: opts.Value, }}, U: deck.User(opts.User), }, nil } // ApplyTag inserts a tagxref row and propagates without creating a control artifact. // Used by Crosslink to process existing control artifacts. func ApplyTag(r *repo.Repo, opts ApplyOpts) error { if r == nil { panic("tag.ApplyTag: r must not be nil") } if opts.TagName == "" { panic("tag.ApplyTag: opts.TagName must not be empty") } if opts.TargetRID <= 0 { panic("tag.ApplyTag: opts.TargetRID must be positive") } return r.WithTx(func(tx *db.Tx) error { if opts.Leaves != nil { return ApplyTagWithTx(tx, opts) } // The transaction is the batch: its rechecks are applied before it // commits. opts.Leaves = leaf.NewPending() if err := ApplyTagWithTx(tx, opts); err != nil { return err } return opts.Leaves.Apply(tx) }) } // ApplyTagWithTx inserts a tagxref row and propagates using an existing transaction. // This avoids the nested-transaction problem when called from within Rebuild's // single wrapping transaction. Identical logic to ApplyTag but accepts a db.Querier. func ApplyTagWithTx(q db.Querier, opts ApplyOpts) error { if q == nil { panic("tag.ApplyTagWithTx: q must not be nil") } if opts.TagName == "" { panic("tag.ApplyTagWithTx: opts.TagName must not be empty") } if opts.TargetRID <= 0 { panic("tag.ApplyTagWithTx: opts.TargetRID must be positive") } tagid, err := ensureTag(q, opts.TagName) if err != nil { return fmt.Errorf("ensure tag %q: %w", opts.TagName, err) } if tagid == leaf.BranchTagID && opts.Leaves == nil { panic("tag.ApplyTagWithTx: a branch tag needs opts.Leaves") } skip, err := superseded(q, tagid, opts.TargetRID, opts.MTime) if err != nil { return err } if skip { return nil } reach, err := replacesPropagatingBranch(q, tagid, opts) if err != nil { return err } if _, err := q.Exec( `INSERT OR REPLACE INTO tagxref(tagid, tagtype, srcid, origid, value, mtime, rid) VALUES(?, ?, ?, ?, ?, ?, ?)`, tagid, opts.TagType, opts.SrcRID, opts.TargetRID, nullableValue(opts.Value), opts.MTime, opts.TargetRID, ); err != nil { return fmt.Errorf("tagxref insert: %w", err) } // tag_insert schedules the check-in and its parents once its branch row // is written (src/tag.c:195), whatever the tag type. if tagid == leaf.BranchTagID { if err := opts.Leaves.Schedule(q, opts.TargetRID); err != nil { return err } } if err := applyEventOverride(q, opts); err != nil { return err } if err := applyDateOverride(q, opts); err != nil { return err } if err := propagateAfterInsert(q, tagid, opts, reach); err != nil { return fmt.Errorf("propagate: %w", err) } return nil } // eventTagColumns maps the tags that override a check-in's derived event row // to the column each one sets, as tag_insert's switch does (src/tag.c). var eventTagColumns = map[string]string{ "bgcolor": "bgcolor", "user": "euser", "comment": "ecomment", } // applyEventOverride sets event.bgcolor, euser or ecomment from a bgcolor, // user or comment tag that has just won its tagxref row. Like tag_insert it // does this for every tag type, writing NULL for a cancel, so a singleton or // cancelled bgcolor takes effect too. A check-in crosslinked later reads the // same rows back; see crosslinkCheckinTables. func applyEventOverride(q db.Querier, opts ApplyOpts) error { col, ok := eventTagColumns[opts.TagName] if !ok { return nil } var value any if opts.TagType != TagCancel { // Empty maps to NULL so this agrees with the tagxref read-back // crosslink does later. value = nullableValue(opts.Value) } // col comes from the fixed map above, never from input. stmt := "UPDATE event SET " + col + "=? WHERE objid=?" if _, err := q.Exec(stmt, value, opts.TargetRID); err != nil { return fmt.Errorf("%s update: %w", opts.TagName, err) } return nil } // applyDateOverride re-derives a check-in's event time from its date tag // (`fossil amend --date`) once that tag has won its tagxref row. func applyDateOverride(q db.Querier, opts ApplyOpts) error { if opts.TagName != "date" { return nil } if err := RefreshEventTime(q, opts.TargetRID); err != nil { return fmt.Errorf("date update: %w", err) } return nil } // RefreshEventTime sets rid's event.mtime from its date tag, following // draft-fossil-repo-state-01 Section 4.2, step 6: the value of a date tag // applied directly to rid (srcid > 0), when it parses (by SQLite // julianday, which already bounds the upper end) to a positive time; // otherwise the D-card time kept in event.omtime. An inherited row // (srcid 0, from a propagating tag on an ancestor) and a cancel set nothing. // // Both arrival orders call this, tag application after it wins its row and // crosslink after it inserts the event row, so the result depends only on // tagxref, never on which came first. That is where -01 departs from fossil // 2.28: its tag_insert writes julianday(value) as is, so a cancel or an // unparsable value leaves no time, 0 stores a time of 0, and its // manifest_crosslink reads an inherited row too. omtime keeps the D-card // time (RS-4.2-15); coalesce fills it for an event row that predates it. // A missing event row (a phantom target) is a no-op. func RefreshEventTime(q db.Querier, rid libfossil.FslID) error { if q == nil { panic("tag.RefreshEventTime: q must not be nil") } if rid <= 0 { panic("tag.RefreshEventTime: rid must be positive") } // In an UPDATE every right-hand side reads the row as it was, so the // fallback is the stored omtime, or, for a row without one, the old mtime. _, err := q.Exec( `UPDATE event SET mtime=coalesce((SELECT jd FROM (SELECT julianday(value) AS jd FROM tagxref WHERE rid=?1 AND srcid>0 AND tagtype>0 AND tagid=(SELECT tagid FROM tag WHERE tagname='date')) WHERE jd>0 AND jd<5373484.5), omtime, mtime), omtime=coalesce(omtime, mtime) WHERE objid=?1`, rid, ) return err } // superseded reports whether tagxref already holds a row for (tagid, rid) // whose mtime is at least as new as the application being attempted. // // Canonical fossil opens tag_insert with exactly this query and returns // without touching tagxref -- and, just as importantly, without calling // tag_propagate -- when it matches (src/tag.c:173-186): // // SELECT 1 FROM tagxref WHERE tagid=%d AND rid=%d AND mtime>=:mtime // ... // if( rc==SQLITE_ROW ){ // /* Another entry that is more recent already exists. Do nothing */ // return tagid; // } // // This is what makes the resulting tagxref state independent of the order // artifacts are applied in. A check-in's inline T-cards and a control artifact // that later retagged the same check-in reach us in whichever order the sweep // visits them; the newer one has to win either way. Dropping the propagation // matters as much as dropping the write: replaying a stale branch value from // a check-in that has since been retagged floods every descendant with it // (issue #198). // // The comparison is >=, not >, so equal mtimes leave the standing row alone, // matching fossil. func superseded(q db.Querier, tagid int64, rid libfossil.FslID, mtime float64) (bool, error) { var one int err := q.QueryRow( "SELECT 1 FROM tagxref WHERE tagid=? AND rid=? AND mtime>=?", tagid, rid, mtime, ).Scan(&one) if errors.Is(err, sql.ErrNoRows) { return false, nil } if err != nil { return false, fmt.Errorf("tagxref mtime check: %w", err) } return true, nil } // propagateAfterInsert runs the propagation half of canonical's tag_insert, // which downgrades a singleton to a cancel before propagating and then // propagates unconditionally (src/tag.c:239): // // if( tagtype==1 ) tagtype = 0; // tag_propagate(rid, tagid, tagtype, rid, zValue, mtime); // // A singleton (+) tag therefore does not merely fail to propagate its own // value -- it actively blocks a same-named propagating tag at this artifact, // deleting the copies descendants had inherited. Confirmed against fossil // 2.28: applying `tag add --raw zz A v2` over an inherited `zz` removes the // child's row. func propagateAfterInsert( q db.Querier, tagid int64, opts ApplyOpts, reachAbsent bool, ) error { tagType := opts.TagType if tagType == TagSingleton { tagType = TagCancel } pr := propagation{ tagid: tagid, tagType: tagType, origID: opts.TargetRID, mtime: opts.MTime, value: opts.Value, tagName: opts.TagName, leaves: opts.Leaves, reachAbsent: reachAbsent && tagType == TagCancel, } return pr.propagate(q, opts.TargetRID) } // replacesPropagatingBranch is whether this write replaces a propagating // branch row with a cancel or a singleton. The row it replaces was handed down // the primary children when it landed, in fossil, and the walk deletes those // copies and rechecks each (src/tag.c:100-102). A crosslink batch defers // propagation to its repair gate, so the copies may not exist yet; the walk // then reaches the children that hold none (propagation.reachAbsent), which are // the check-ins fossil would have rechecked (Section 7.3, RS-7.3-3). Only a // branch tag matters: no other tag schedules a recheck. func replacesPropagatingBranch(q db.Querier, tagid int64, opts ApplyOpts) (bool, error) { if tagid != leaf.BranchTagID || opts.TagType == TagPropagating { return false, nil } var old int err := q.QueryRow("SELECT tagtype FROM tagxref WHERE tagid=? AND rid=?", tagid, opts.TargetRID).Scan(&old) if errors.Is(err, sql.ErrNoRows) { return false, nil } if err != nil { return false, fmt.Errorf("tagxref branch row: %w", err) } return old == TagPropagating, nil } // ensureTag returns the tagid for the given tag name, creating it if it doesn't exist. func ensureTag(q db.Querier, name string) (int64, error) { if q == nil { panic("tag.ensureTag: q must not be nil") } if name == "" { panic("tag.ensureTag: name must not be empty") } var tagid int64 err := q.QueryRow("SELECT tagid FROM tag WHERE tagname=?", name).Scan(&tagid) if err == nil { return tagid, nil } result, err := q.Exec("INSERT INTO tag(tagname) VALUES(?)", name) if err != nil { return 0, err } return result.LastInsertId() } // nullableValue is the tagxref.value for a tag value: NULL for a tag that has // none, as fossil stores it (#258). A T card cannot carry an empty value, so // "" always means absent; storing an empty string would read as a tag with // an empty value. func nullableValue(v string) any { if v == "" { return nil } return v }