Artifact 55727ed389513c6fbbbf544d4f3d69304927f1f5ce3d46afca05603e2f035fd2:
- File
internal/manifest/leaf.go
— part of check-in
[df495ba69e]
at
2026-07-27 22:04:05
on branch trunk
— fix: derive leaf by canonical's branch rule, in both the sweep and the commit path (#197)
* fix: derive leaf from fossil's branch rule and bound the deferral rollup
Leaf derivation dropped 66% of rows on the real Fossil repository (487 of 1,426) because both leaf paths treated any parent link as retiring a leaf. Canonical fossil retires a check-in only when a child carries the *same* branch name, with an absent branch tag reading as "trunk" (src/leaf.c: is_a_leaf / leaf_check / leaf_rebuild all share that predicate and none consult plink.isprim). Verified against fossil 2.28 itself: after a branch commit off the trunk tip, both the incremental table and `fossil rebuild` keep the trunk tip as a leaf even though its only child is its primary child, and a branch tip merged into trunk stays a leaf as well.
Two code paths were wrong, in the same way:
- repairLeafTable excluded every `pid` in plink, so merged branch tips disappeared -- the class that accounts for 939 of the corpus's leaves. - Checkin deleted every parent from leaf, so committing onto a new branch silently retired the branch point.
Both now share leafCheck/repairLeafTable in internal/manifest/leaf.go. The recompute also has to run after tag propagation rather than before it: a check-in that declares no branch of its own only has a branch value once propagation has written it, and comparing against a missing value made every inherited-branch tip look like a leaf.
Applying the new predicate to the real repository's own event/plink/tagxref yields 1,428 against fossil's 1,426, and every one of the six differing rows is fossil's incremental bookkeeping rather than its predicate: 2 are phantom rids fossil's leaf_check inserts as plink parents of check-ins it linked, and 4 are parentless check-ins with no branch tag, which nothing ever enqueues for a leaf check. Running fossil's own leaf_rebuild() on that repository gives 1,494, so it is leaf_check, not leaf_rebuild, that the shipped table reflects.
Also caps the deferral rollup's identifier lists (issue #194). A clone deferring 180 check-ins on 417 missing blobs rendered a single ~28 KB log line; the counts stay exact at INFO and the lists print eight entries plus an "...and N more" so nothing is dropped without saying so.
Fixes #189 Fixes #194
* chore: retrigger code scanning after branch restore (user: [email protected], size: 4630) [annotate] [blame] [check-ins using]
package manifest import ( "database/sql" "errors" "fmt" "github.com/danmestas/go-libfossil/db" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" ) // Leaf maintenance, in one place so the sweep and the local commit path cannot // drift apart on what a leaf is. // // Canonical fossil's rule (src/leaf.c, leaf_check/is_a_leaf/leaf_rebuild): a // check-in stops being a leaf only once a child of it carries the *same* branch // name, where a check-in with no branch tag reads as "trunk": // // SELECT 1 FROM plink // WHERE pid=:rid // AND coalesce((SELECT value FROM tagxref // WHERE tagid=TAG_BRANCH AND rid=plink.pid),'trunk') // == coalesce((SELECT value FROM tagxref // WHERE tagid=TAG_BRANCH AND rid=plink.cid),'trunk') // // Two consequences are easy to get wrong and are what issue #189 was: // // - The merge parent of a check-in on another branch stays a leaf. A branch // merged back into trunk keeps its tip in the leaf table -- that class was // 939 of the 1,426 leaves the real Fossil repository has. // - The plink row's isprim flag is not consulted at all. A branch point stays // a leaf even though its only child is its *primary* child, because that // child is on a different branch. // // The branch comparison reads tagxref, so both entry points must run after the // branch tag of every check-in involved is in tagxref -- including the ones a // check-in inherits by propagation rather than declaring itself. // // branchTagID is the tagid db/schema.go seeds for 'branch', which is fossil's // own TAG_BRANCH; both schemas fix it, so it is a constant here rather than a // lookup per row of a whole-repository recompute. const branchTagID = 8 // leafCheck brings rid's leaf row in line with the rule above, inserting or // deleting as needed. It is fossil's leaf_check(), and like fossil's it is // called on a check-in and on each of its parents once the surrounding write is // otherwise complete (leaf_eventually_check + leaf_do_pending_checks). func leafCheck(q db.Querier, rid libfossil.FslID) error { if q == nil { panic("manifest.leafCheck: q must not be nil") } if rid <= 0 { panic("manifest.leafCheck: rid must be positive") } var one int err := q.QueryRow(fmt.Sprintf(` SELECT 1 FROM plink WHERE plink.pid = ? AND coalesce((SELECT value FROM tagxref WHERE tagid=%d AND rid=plink.pid),'trunk') = coalesce((SELECT value FROM tagxref WHERE tagid=%d AND rid=plink.cid),'trunk') LIMIT 1`, branchTagID, branchTagID), rid).Scan(&one) switch { case err == nil: if _, err := q.Exec("DELETE FROM leaf WHERE rid=?", rid); err != nil { return fmt.Errorf("leafCheck delete rid=%d: %w", rid, err) } return nil case errors.Is(err, sql.ErrNoRows): if _, err := q.Exec("INSERT OR IGNORE INTO leaf(rid) VALUES(?)", rid); err != nil { return fmt.Errorf("leafCheck insert rid=%d: %w", rid, err) } return nil default: return fmt.Errorf("leafCheck query rid=%d: %w", rid, err) } } // repairLeafTable recomputes leaf from scratch. Crosslink does not maintain // leaf incrementally as each checkin is linked -- that only produces the right // answer when parents are always linked before their children, which // delta-chain order does not guarantee -- so the whole table is rebuilt once // the sweep's plink edges and propagated branch tags are all in place. // // This is fossil's leaf_rebuild() with one deliberate difference: fossil draws // its candidates from `SELECT cid FROM plink`, which cannot return a check-in // that has no parent, and reaches those through leaf_check() instead. Drawing // candidates from event covers both in one statement, which is why a repository // holding a single parentless check-in ends up with the one leaf row fossil // gives it too. func repairLeafTable(q db.Querier) error { if q == nil { panic("manifest.repairLeafTable: q must not be nil") } if _, err := q.Exec("DELETE FROM leaf"); err != nil { return fmt.Errorf("repairLeafTable clear: %w", err) } if _, err := q.Exec(fmt.Sprintf(` INSERT INTO leaf(rid) SELECT e.objid FROM event e WHERE e.type='ci' AND NOT EXISTS( SELECT 1 FROM plink WHERE plink.pid = e.objid AND coalesce((SELECT value FROM tagxref WHERE tagid=%d AND rid=plink.pid),'trunk') = coalesce((SELECT value FROM tagxref WHERE tagid=%d AND rid=plink.cid),'trunk')) `, branchTagID, branchTagID)); err != nil { return fmt.Errorf("repairLeafTable insert: %w", err) } return nil }