Artifact 2184d424fd7f7c12df3352724858a328b4fa706f1e8a00234bba5c517ad99e3e:
- File
internal/manifest/manifest.go
— part of check-in
[9c071c7c22]
at
2026-10-12 19:37:25
on branch main
— fix(branch): check the open branch name in the check-in transaction (#316) (#346)
branch.Create refused a name an open leaf carries (fossil's branch_is_open), but checked before manifest.Checkin opened its transaction, and Checkin applied the branch tags, parent propagation and leaf state in later transactions. Concurrent creates of one name could all pass the check and all commit.
Checkin now derives its tags and leaf state inside its own transaction and runs an optional Precheck first in it. Create checks the name there, so check and write are one transaction: with BEGIN IMMEDIATE the second writer sees the first's open leaf and is refused. Create's redundant second tag pass after the check-in goes away. (user: [email protected], size: 15718) [annotate] [blame] [check-ins using]
package manifest import ( "fmt" "sort" "strings" "time" "unicode/utf8" "github.com/danmestas/go-libfossil/db" "github.com/danmestas/go-libfossil/internal/blob" "github.com/danmestas/go-libfossil/internal/content" "github.com/danmestas/go-libfossil/internal/deck" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" "github.com/danmestas/go-libfossil/internal/repo" "github.com/danmestas/go-libfossil/internal/tag" ) type CheckinOpts struct { Files []File Comment string User string Parent libfossil.FslID MergeParents []libfossil.FslID // additional parents beyond Parent (for merge commits) Delta bool Time time.Time Tags []deck.TagCard // Precheck, when set, runs first inside the check-in's transaction, and // an error from it aborts the check-in before anything is stored. It makes // a caller's read-then-write decision atomic with the write: branch.Create // refuses an open branch name here, so two concurrent creates of one name // cannot both commit. It must only read: a write would land in the // check-in's transaction. Precheck func(q db.Querier) error } type File struct { Name string Content []byte Perm string OldName string // prior pathname when this file card records a rename } func Checkin(r *repo.Repo, opts CheckinOpts) (manifestRid libfossil.FslID, manifestUUID string, err error) { if r == nil { panic("manifest.Checkin: r must not be nil") } if len(opts.Files) == 0 { panic("manifest.Checkin: opts.Files must not be empty") } if opts.User == "" { panic("manifest.Checkin: opts.User must not be empty") } defer func() { // If a panic is already unwinding the stack (e.g. a lower-level // invariant violation inside the transaction body), that panic is // the real diagnosis and must propagate unchanged. Without this // check, the postcondition assertion below fires unconditionally // during unwind too โ manifestRid is still its zero value because // the panicking call never returned โ replacing the original // panic with an unrelated "manifestRid must be positive" message // and masking the actual cause (issue #68). if p := recover(); p != nil { panic(p) } if err == nil && manifestRid <= 0 { panic("manifest.Checkin: manifestRid must be positive on success") } }() if opts.Time.IsZero() { opts.Time = time.Now().UTC() } err = r.WithTx(func(tx *db.Tx) error { if opts.Precheck != nil { if txErr := opts.Precheck(tx); txErr != nil { return txErr } } fCards, fileRids, txErr := storeFileBlobs(tx, opts.Files) if txErr != nil { return txErr } d, txErr := buildCheckinDeck(tx, opts, fCards) if txErr != nil { return txErr } cache := content.NewCache(crosslinkCacheBytes) if txErr := preflightCheckinMlinks(tx, cache, d); txErr != nil { return fmt.Errorf("preflight checkin mlinks: %w", txErr) } manifestRid, manifestUUID, txErr = insertCheckinBlob(tx, d) if txErr != nil { return txErr } if txErr := insertCheckinMlinks(tx, cache, manifestRid, d); txErr != nil { return txErr } // Store the parent check-in manifest as a delta against this one, // the same backwards direction used for file content. Canonical does // this at the end of commit (src/checkin.c:3234, // `content_deltify(vid, &nvid, 1, 0)`). File content is handled by // insertCheckinMlinks above; this covers the manifests themselves. if opts.Parent > 0 { if _, txErr := content.Deltify(tx, opts.Parent, manifestRid); txErr != nil { return fmt.Errorf("deltify parent manifest: %w", txErr) } } if txErr := markPlinkAndEvent(tx, opts, manifestRid); txErr != nil { return txErr } // Mark file blobs as unsent so sync pushes them. // (unclustered is handled by blob.Store automatically.) for _, frid := range fileRids { if _, err := tx.Exec("INSERT OR IGNORE INTO unsent(rid) VALUES(?)", frid); err != nil { return fmt.Errorf("unsent file: %w", err) } } return linkCheckinTags(tx, opts, manifestRid, d.T) }) if err != nil { return 0, "", fmt.Errorf("manifest.Checkin: %w", err) } return manifestRid, manifestUUID, nil } // linkCheckinTags derives a new check-in's tag and leaf state on the check-in's // own transaction, after its event and plink rows, so the check-in commits // with its branch already in effect: a reader never sees it untagged, and a // Precheck in a later check-in sees it. func linkCheckinTags( tx *db.Tx, opts CheckinOpts, manifestRid libfossil.FslID, tcards []deck.TagCard, ) error { // Inline T-cards (branch, sym-trunk, etc.) name the check-in itself. for _, tc := range tcards { if tc.UUID != "*" { continue // non-self T-cards are for control artifacts } var tagType int switch tc.Type { case deck.TagPropagating: tagType = tag.TagPropagating case deck.TagSingleton: tagType = tag.TagSingleton case deck.TagCancel: tagType = tag.TagCancel default: continue } if err := tag.ApplyTagWithTx(tx, tag.ApplyOpts{ TargetRID: manifestRid, SrcRID: manifestRid, TagName: tc.Name, TagType: tagType, Value: tc.Value, MTime: libfossil.TimeToJulian(opts.Time), }); err != nil { return fmt.Errorf("inline tag %q: %w", tc.Name, err) } } // Inherit propagating tags (branch, sym-*, bgcolor) from the primary parent. // Without this, a new commit on an existing branch would not carry its // parent's branch tag โ breaking branch-aware queries. A local commit is // always linked immediately after its parent exists, so calling this // here (rather than deferring to a repair pass) is safe; crosslink's // sync-import path defers the equivalent work to repairTagPropagation // instead, since candidates there are not visited in parent-before-child // order. Both converge on the same tagxref state. if opts.Parent > 0 { if err := tag.PropagateAll(tx, opts.Parent); err != nil { return fmt.Errorf("propagate from parent: %w", err) } } // Leaf state last, once every branch tag it reads is in place: this checkin // is a new leaf, and each parent stops being one only if this checkin // landed on the parent's own branch. Fossil queues the same set -- // leaf_eventually_check(rid) enqueues rid and its parents -- and runs the // checks when the transaction commits. for _, rid := range append([]libfossil.FslID{manifestRid}, collectParents(opts)...) { if err := leafCheck(tx, rid); err != nil { return err } } return nil } // checkWriterName refuses a filename the artifact format's writer profile // forbids (section 4.6.1): a backslash, or bytes that are not valid UTF-8, // which includes surrogates and overlong forms. A parser accepts them, so a // manifest naming one would be stored by a reader but is not one a writer // may create. The empty name is OldName's "no rename" and passes. func checkWriterName(name string) error { if strings.ContainsRune(name, '\\') { return fmt.Errorf("filename %q contains a backslash", name) } if !utf8.ValidString(name) { return fmt.Errorf("filename %q is not valid UTF-8", name) } return nil } func storeFileBlobs(tx *db.Tx, files []File) ([]deck.FileCard, []libfossil.FslID, error) { fCards := make([]deck.FileCard, len(files)) var rids []libfossil.FslID for i, f := range files { if err := checkWriterName(f.Name); err != nil { return nil, nil, err } if err := checkWriterName(f.OldName); err != nil { return nil, nil, err } rid, uuid, err := blob.Store(tx, f.Content) if err != nil { return nil, nil, fmt.Errorf("storing file %q: %w", f.Name, err) } fCards[i] = deck.FileCard{Name: f.Name, UUID: uuid, Perm: f.Perm, OldName: f.OldName} rids = append(rids, rid) } return fCards, rids, nil } func buildCheckinDeck(tx *db.Tx, opts CheckinOpts, fCards []deck.FileCard) (*deck.Deck, error) { d := &deck.Deck{ Type: deck.Checkin, C: opts.Comment, D: opts.Time, F: fCards, U: deck.User(opts.User), } // Parents: primary parent first, then any merge parents. Fossil renders // this as a space-separated P-card; plink marks i==0 as the primary. if parents := collectParents(opts); len(parents) > 0 { d.P = make([]string, 0, len(parents)) for i, parent := range parents { var parentUUID string if err := tx.QueryRow("SELECT uuid FROM blob WHERE rid=?", parent).Scan(&parentUUID); err != nil { kind := "merge parent" if i == 0 { kind = "primary parent" } return nil, fmt.Errorf("%s uuid: %w", kind, err) } d.P = append(d.P, parentUUID) } } // Tags: use custom tags if provided, otherwise default trunk tags for initial checkin if len(opts.Tags) > 0 { d.T = opts.Tags } else if opts.Parent == 0 { // Both tags propagate, matching `fossil new` and repo-state ยง6.1: the // branch name and its sym- alias descend to every child on the branch. // sym-trunk as a singleton (+) would stop at the root (issue #289). d.T = []deck.TagCard{ {Type: deck.TagPropagating, Name: "branch", UUID: "*", Value: "trunk"}, {Type: deck.TagPropagating, Name: "sym-trunk", UUID: "*"}, } } // Delta manifest support if opts.Delta && opts.Parent > 0 { if err := applyDelta(tx, d, fCards, opts.Parent); err != nil { return nil, err } } sort.Slice(d.F, func(i, j int) bool { return deck.Compare(d.F[i].Name, d.F[j].Name) < 0 }) // R-card (always over full file set) rDeck := &deck.Deck{F: fCards} // Bare Expand: computing one checkin's R-card expands each file in the // tree exactly once. Distinct blobs, single pass, no overlap to amortize. getContent := func(uuid string) ([]byte, error) { rid, ok := content.AvailableByUUID(tx, uuid) if !ok { return nil, fmt.Errorf("blob not found: %s", uuid) } return content.Expand(tx, rid) } rHash, err := rDeck.ComputeR(getContent) if err != nil { return nil, fmt.Errorf("R-card: %w", err) } d.R = rHash return d, nil } func insertCheckinBlob(tx *db.Tx, d *deck.Deck) (libfossil.FslID, string, error) { manifestBytes, err := d.Marshal() if err != nil { return 0, "", fmt.Errorf("marshal: %w", err) } rid, uuid, err := blob.Store(tx, manifestBytes) if err != nil { return 0, "", fmt.Errorf("store manifest: %w", err) } return rid, uuid, nil } func markPlinkAndEvent(tx *db.Tx, opts CheckinOpts, manifestRid libfossil.FslID) error { // plink: one row per parent; primary (isprim=1) is opts.Parent, merge // parents are secondary (isprim=0). Matches the sync-path layout in // crosslink.go so merged history looks identical however it arrived. mtime := libfossil.TimeToTextJulian(opts.Time) allParents := collectParents(opts) for i, pid := range allParents { isPrim := 0 if i == 0 { isPrim = 1 } if _, err := tx.Exec( "INSERT INTO plink(pid, cid, isprim, mtime) VALUES(?, ?, ?, ?)", pid, manifestRid, isPrim, mtime, ); err != nil { return fmt.Errorf("plink: %w", err) } } // event. omtime is the check-in's own time, as fossil keeps it. if _, err := tx.Exec( `INSERT INTO event(type, mtime, objid, user, comment, omtime) VALUES('ci', ?, ?, ?, ?, ?)`, mtime, manifestRid, opts.User, opts.Comment, mtime, ); err != nil { return fmt.Errorf("event: %w", err) } // leaf is deliberately not written here. Whether this checkin or any of its // parents is a leaf depends on the branch tags of both ends of every plink // edge, and this checkin's own branch tag is not applied until after the // transaction commits -- so Checkin runs the leaf check once the tag work // is done, the same deferral fossil gets from running its pending leaf // checks at commit time (see leafCheck). // Mark manifest as unsent so sync pushes it (unclustered is handled by blob.Store). if _, err := tx.Exec("INSERT OR IGNORE INTO unsent(rid) VALUES(?)", manifestRid); err != nil { return fmt.Errorf("unsent: %w", err) } return nil } // collectParents returns the primary parent followed by any merge parents, // skipping zero entries and de-duplicating. Used for both the plink insert // loop and the leaf cleanup so both see the same set. func collectParents(opts CheckinOpts) []libfossil.FslID { if opts.Parent <= 0 { return nil } seen := map[libfossil.FslID]bool{opts.Parent: true} out := []libfossil.FslID{opts.Parent} for _, mp := range opts.MergeParents { if mp <= 0 || seen[mp] { continue } seen[mp] = true out = append(out, mp) } return out } func ensureFilename(tx *db.Tx, name string) (int64, error) { if tx == nil { panic("manifest.ensureFilename: tx must not be nil") } if name == "" { panic("manifest.ensureFilename: name must not be empty") } var fnid int64 err := tx.QueryRow("SELECT fnid FROM filename WHERE name=?", name).Scan(&fnid) if err == nil { return fnid, nil } result, err := tx.Exec("INSERT INTO filename(name) VALUES(?)", name) if err != nil { return 0, err } return result.LastInsertId() } func GetManifest(r *repo.Repo, rid libfossil.FslID) (*deck.Deck, error) { return GetManifestCached(r, rid, nil) } // GetManifestCached is GetManifest that serves the manifest's own delta-chain // expansion through cache. A nil cache behaves exactly like GetManifest: // content.Cache's nil receiver falls through to content.Expand. Passing a live // cache pays off only for a caller that reads many manifests whose delta chains // overlap -- annotate walking one file's whole history re-expands the same // chain interiors at every revision -- so a single-manifest read gains nothing // and should pass nil. func GetManifestCached(r *repo.Repo, rid libfossil.FslID, cache *content.Cache) (result *deck.Deck, err error) { if r == nil { panic("manifest.GetManifest: r must not be nil") } if rid <= 0 { panic("manifest.GetManifest: rid must be positive") } defer func() { if err == nil && result == nil { panic("manifest.GetManifest: result must not be nil on success") } }() data, err := cache.Expand(r.DB(), rid) if err != nil { return nil, fmt.Errorf("manifest.GetManifest: %w", err) } return deck.Parse(data) } func applyDelta(tx *db.Tx, d *deck.Deck, fullFCards []deck.FileCard, parentRid libfossil.FslID) error { // Bare Expand: building one delta manifest reads its parent and baseline // once each. This is the commit path, not a history sweep -- no repeated // overlapping-chain access for a cache to pay off against. parentData, err := content.Expand(tx, parentRid) if err != nil { return fmt.Errorf("expand parent: %w", err) } parentDeck, err := deck.Parse(parentData) if err != nil { return fmt.Errorf("parse parent: %w", err) } baselineUUID := parentDeck.B if baselineUUID == "" { var puuid string tx.QueryRow("SELECT uuid FROM blob WHERE rid=?", parentRid).Scan(&puuid) baselineUUID = puuid } baseRid, ok := content.AvailableByUUID(tx, baselineUUID) if !ok { return fmt.Errorf("baseline %s not found", baselineUUID) } baseData, err := content.Expand(tx, baseRid) if err != nil { return fmt.Errorf("expand baseline: %w", err) } baseDeck, err := deck.Parse(baseData) if err != nil { return fmt.Errorf("parse baseline: %w", err) } baseFiles := make(map[string]string) for _, f := range baseDeck.F { baseFiles[f.Name] = f.UUID } var deltaFCards []deck.FileCard currentFiles := make(map[string]bool) for _, f := range fullFCards { currentFiles[f.Name] = true if baseUUID, exists := baseFiles[f.Name]; !exists || baseUUID != f.UUID { deltaFCards = append(deltaFCards, f) } } for name := range baseFiles { if !currentFiles[name] { deltaFCards = append(deltaFCards, deck.FileCard{Name: name}) } } if len(deltaFCards) < len(fullFCards) { d.B = baselineUUID d.F = deltaFCards } return nil }