Artifact 81fb35a3b3a18616d080a2801b90d8a37c0b590cfcbccbfe176fa57d2ed5869a:
- File
internal/verify/rebuild.go
— part of check-in
[33959a6e3e]
at
2026-10-12 17:34:38
on branch main
— fix(verify): derive Rebuild state through the crosslink sweep (#285) (#336)
verify.Rebuild kept its own partial derivation of repository state: it wrote event/plink/mlink/tagxref rows only for check-ins and control artifacts, applied tags from artifacts the crosslink would refuse, skipped absent tag targets and parents instead of reserving phantoms, wrote no plink baseid, and computed leaves as "no child at all" regardless of branch. Against fossil rebuild it missed hundreds of event, plink, tagxref and leaf rows on real repositories.
Make Rebuild empty the crosslink-derived tables and run the same sweep a clone runs, so derived state has a single owner. The three partial derivation files (rebuild_tags.go, rebuild_manifest.go, rebuild_leaves.go) are deleted; Rebuild keeps only what the sweep does not do -- blob hash verification and the unclustered/unsent bookkeeping.
A rebuild is terminal: nothing more will arrive, so a check-in whose file or baseline content is absent must be linked with phantoms rather than deferred for a blob that never comes, as fossil's add_one_mlink does via uuid_to_rid(...,1). Add manifest.CrosslinkRebuild, a sweep that does not defer; on a complete repository it is identical to Crosslink. Rebuild's leaf rule now follows fossil's branch-aware sweep, so an untagged parentless check-in is no longer a leaf -- the same Section 7.3 deviation the sweep and per-artifact paths already carry. (user: [email protected], size: 5530) [annotate] [blame] [check-ins using]
package verify import ( "database/sql" "errors" "fmt" "time" "github.com/danmestas/go-libfossil/db" "github.com/danmestas/go-libfossil/internal/content" "github.com/danmestas/go-libfossil/internal/manifest" "github.com/danmestas/go-libfossil/internal/repo" ) // crosslinkTables are the derived tables a crosslink sweep owns. Rebuild empties // them so every blob is a candidate again, then lets manifest.Crosslink refill // them exactly as a clone that received every artifact would. This is the same // set the repository-state corpus test empties before its sweep, which matches // `fossil rebuild` in event, plink, mlink, tagxref, leaf and phantom. var crosslinkTables = []string{ "event", "plink", "leaf", "mlink", "tagxref", "forumpost", "attachment", "backlink", "cherrypick", } // bookkeepingTables are the sync-bookkeeping tables the crosslink sweep does not // own. fossil's rebuild repopulates them; Rebuild clears and refills them after // the sweep so a rebuilt repository offers every artifact to its next push. var bookkeepingTables = []string{"unclustered", "unsent"} // rebuildTableList reports every table Rebuild reconstructs, for Report. var rebuildTableList = append(append([]string{}, crosslinkTables...), bookkeepingTables...) // Rebuild reconstructs all derived tables from raw blob content. It first // verifies blob integrity (read-only), then empties the crosslink-derived // tables and runs the same crosslink sweep a clone runs, so derived state has a // single owner -- manifest.Crosslink -- rather than a second, partial // derivation that diverged from `fossil rebuild`. Finally it refills the // sync-bookkeeping tables the sweep does not own. func Rebuild(r *repo.Repo) (*Report, error) { if r == nil { panic("verify.Rebuild: nil *repo.Repo") } start := time.Now() report := &Report{} // Phase 1: verify blobs (read-only, outside any transaction). This is the // responsibility the crosslink sweep does not carry: every blob is expanded // and its content hashed against its stored UUID, so a corrupt or // delta-dangling repository is reported before any derived row is rewritten. cache := content.NewCache(verifyCacheBytes) if err := checkBlobs(r, report, cache); err != nil { return nil, fmt.Errorf("verify.Rebuild: %w", err) } // Phase 2: empty the crosslink-derived tables so every blob owes the sweep // an examination again. The deletes commit before the sweep because // manifest.Crosslink discovers candidates with a read outside any // transaction. if err := r.WithTx(func(tx *db.Tx) error { return clearTables(tx, crosslinkTables) }); err != nil { return nil, fmt.Errorf("verify.Rebuild: %w", err) } // Phase 3: crosslink every artifact. The sweep writes event/plink/mlink/ // tagxref, creates phantoms for absent targets, parents and file content, // reparents, then repairs leaf and tag-propagation state over the whole plink // graph -- the order `fossil rebuild` uses. CrosslinkRebuild, not Crosslink, // because a rebuild is terminal: a check-in whose content is absent is linked // with phantoms rather than deferred for a blob that will never arrive. if _, err := manifest.CrosslinkRebuild(r); err != nil { return nil, fmt.Errorf("verify.Rebuild: %w", err) } // Phase 4: refill the sync-bookkeeping tables the sweep does not own. if err := r.WithTx(rebuildBookkeeping); err != nil { return nil, fmt.Errorf("verify.Rebuild: %w", err) } report.TablesRebuilt = rebuildTableList report.Duration = time.Since(start) // Postcondition: TablesRebuilt must be populated after successful rebuild. if len(report.TablesRebuilt) == 0 { panic("verify.Rebuild: postcondition: TablesRebuilt empty after successful rebuild") } return report, nil } // clearTables deletes all rows from the named tables that exist. A table may be // absent: a repository created by fossil(1) without the feature that owns it // (forumpost until the first forum post, say) has no such table until the // crosslink sweep's prepareCrosslinkTables creates it, and there is nothing to // clear in that case. func clearTables(tx *db.Tx, tables []string) error { if tx == nil { panic("verify.clearTables: nil *db.Tx") } for _, table := range tables { exists, err := tableExists(tx, table) if err != nil { return err } if !exists { continue } if _, err := tx.Exec("DELETE FROM " + table); err != nil { return fmt.Errorf("clear %s: %w", table, err) } } return nil } // tableExists reports whether the named table is present in the schema. func tableExists(tx *db.Tx, table string) (bool, error) { var name string err := tx.QueryRow( "SELECT name FROM sqlite_master WHERE type='table' AND name=?", table, ).Scan(&name) if errors.Is(err, sql.ErrNoRows) { return false, nil } if err != nil { return false, fmt.Errorf("table exists %s: %w", table, err) } return true, nil } // rebuildBookkeeping repopulates unclustered and unsent. Every non-phantom blob // is marked unclustered and unsent so sync re-evaluates what to push. It clears // first so a repeated Rebuild stays idempotent. func rebuildBookkeeping(tx *db.Tx) error { if tx == nil { panic("verify.rebuildBookkeeping: nil *db.Tx") } if err := clearTables(tx, bookkeepingTables); err != nil { return err } for _, table := range bookkeepingTables { _, err := tx.Exec("INSERT INTO " + table + "(rid) SELECT rid FROM blob WHERE size >= 0") if err != nil { return fmt.Errorf("rebuildBookkeeping %s: %w", table, err) } } return nil }