Artifact 5fc2a90f2977a3c3b6a0ba6ed4e10e1785d67f0622674a049b69f8fd5ec3762e:
- File
internal/checkout/update.go
— part of check-in
[670ca0e75f]
at
2026-07-19 16:00:09
on branch trunk
— fix: separate blob existence from blob availability (#43) (#52)
* fix: separate blob existence from blob availability (#43)
A phantom is a real blob row with size = -1 and NULL content, so blob.Exists — "SELECT rid FROM blob WHERE uuid=?" — answers true for it. Every guard built on Exists therefore admitted artifacts whose content cannot be read, and content.Expand aborted with "rid N is a phantom".
Canonical Fossil keeps two predicates and never substitutes one for the other: rid_from_uuid (existence, xfer.c:70) and content_is_available (availability, content.c:163). The latter is transitive — it follows delta.srcid up the chain, so a delta whose base is a phantom is unavailable even though every row in the chain exists.
Add content.IsAvailable / content.AvailableByUUID, ported from content_is_available including the chain walk, with an explicit depth bound so a cyclic delta table terminates instead of hanging. The walk lives in exactly one place; no caller touches the delta table.
blob.Exists keeps its behavior and gains a doc comment stating that it answers existence only, returns true for phantoms, and naming the availability predicate as the alternative.
manifest.blobPresent is deleted. It was a local patch at one call site that tested size >= 0 without the chain walk, so it still reported a delta-on-phantom as present — the same class of bug it was written to work around.
Every blob.Exists call site was classified as existence or availability and converted where it guards a content read. Sites that want rid identity — plink/mlink/tagxref population, cluster phantom creation, private-status bookkeeping, the post-store sanity check in handleFile, and phantom filling in storeReceivedFile — keep existence, since delta-before-base is a normal steady state during clone.
Verified against a loopback fossil 2.28 server: the 23-artifact repro that previously failed roughly half the time now clones 6 of 6.
* fix: IsAvailable must fail closed when the delta lookup errors
Only sql.ErrNoRows means the chain is grounded. Any other failure — cancelled context, closed connection, NULL srcid — says nothing about groundedness, so returning true there claims readable content that was never verified. That is the same class of bug this predicate exists to prevent, and it runs on data arriving over the wire during clone.
The blob-size lookup above already fails closed on the same pattern; this restores the symmetry. (user: [email protected], size: 12341) [annotate] [blame] [check-ins using]
package checkout import ( "context" "database/sql" "fmt" "os" "path/filepath" "strconv" "time" libfossil "github.com/danmestas/libfossil/internal/fsltype" "github.com/danmestas/libfossil/db" "github.com/danmestas/libfossil/internal/content" "github.com/danmestas/libfossil/internal/manifest" "github.com/danmestas/libfossil/internal/merge" ) // CalcUpdateVersion finds the latest leaf checkin to update to. // Returns 0 if the checkout is already at the latest version. // // Panics if c is nil (TigerStyle precondition). func (c *Checkout) CalcUpdateVersion() (libfossil.FslID, error) { if c == nil { panic("checkout.CalcUpdateVersion: nil *Checkout") } currentRID, _, err := c.Version() if err != nil { return 0, fmt.Errorf("checkout.CalcUpdateVersion: %w", err) } var tipRID int64 err = c.repo.DB().QueryRow(` SELECT l.rid FROM leaf l JOIN event e ON e.objid=l.rid WHERE e.type='ci' ORDER BY e.mtime DESC LIMIT 1 `).Scan(&tipRID) if err != nil { if err == sql.ErrNoRows { return 0, nil // no checkins at all } return 0, fmt.Errorf("checkout.CalcUpdateVersion: %w", err) } if libfossil.FslID(tipRID) == currentRID { return 0, nil // already at latest } return libfossil.FslID(tipRID), nil } // updateFileMaps holds name→UUID maps for the current, target, and ancestor versions. type updateFileMaps struct { current map[string]string target map[string]string ancestor map[string]string allNames map[string]bool } // buildFileMaps builds name→UUID maps for the current, target, and ancestor // file lists, plus the union of all file names. func buildFileMaps(currentFiles, targetFiles, ancestorFiles []manifest.FileEntry) updateFileMaps { m := updateFileMaps{ current: make(map[string]string, len(currentFiles)), target: make(map[string]string, len(targetFiles)), ancestor: make(map[string]string, len(ancestorFiles)), allNames: make(map[string]bool, len(currentFiles)+len(targetFiles)), } for _, f := range currentFiles { m.current[f.Name] = f.UUID m.allNames[f.Name] = true } for _, f := range targetFiles { m.target[f.Name] = f.UUID m.allNames[f.Name] = true } for _, f := range ancestorFiles { m.ancestor[f.Name] = f.UUID } return m } // processFileUpdates iterates over all file names and applies update/merge // logic for each one. Returns stats for the observer. func (c *Checkout) processFileUpdates( ctx context.Context, maps updateFileMaps, strategy merge.Strategy, opts UpdateOpts, checkinTime time.Time, ) (filesWritten, filesRemoved, conflicts int, err error) { for name := range maps.allNames { curUUID, inCurrent := maps.current[name] tgtUUID, inTarget := maps.target[name] ancUUID := maps.ancestor[name] change, ferr := c.updateFile( name, curUUID, tgtUUID, ancUUID, inCurrent, inTarget, strategy, opts.DryRun, ) if ferr != nil { return filesWritten, filesRemoved, conflicts, fmt.Errorf("checkout.Update: %w", ferr) } if change == UpdateNone { continue } // Apply checkin mtime to written files. if opts.SetMTime && !opts.DryRun && !checkinTime.IsZero() && change != UpdateRemoved { fullPath, _ := c.safePath(name) if fullPath != "" { _ = c.env.Storage.Chtimes(fullPath, checkinTime, checkinTime) } } switch change { case UpdateAdded, UpdateUpdated, UpdateMerged: filesWritten++ case UpdateConflictMerged: filesWritten++ conflicts++ c.obs.Error(ctx, fmt.Errorf( "checkout.Update: merge conflict in %s", name, )) case UpdateRemoved: filesRemoved++ } c.obs.ExtractFileCompleted(ctx, name, change) if opts.Callback != nil { if cerr := opts.Callback(name, change); cerr != nil { return filesWritten, filesRemoved, conflicts, fmt.Errorf( "checkout.Update: callback for %s: %w", name, cerr, ) } } } return filesWritten, filesRemoved, conflicts, nil } // buildUpdateMaps gathers file lists for current, target, and their common // ancestor, then builds the combined name→UUID maps for 3-way comparison. func (c *Checkout) buildUpdateMaps(currentRID, target libfossil.FslID) (updateFileMaps, error) { currentFiles, err := manifest.ListFiles(c.repo, currentRID) if err != nil { return updateFileMaps{}, fmt.Errorf("checkout.Update: list current files: %w", err) } targetFiles, err := manifest.ListFiles(c.repo, target) if err != nil { return updateFileMaps{}, fmt.Errorf("checkout.Update: list target files: %w", err) } ancestor, err := merge.FindCommonAncestor(c.repo, currentRID, target) if err != nil { ancestor = currentRID } var ancestorFiles []manifest.FileEntry if ancestor == currentRID { ancestorFiles = currentFiles } else { ancestorFiles, err = manifest.ListFiles(c.repo, ancestor) if err != nil { return updateFileMaps{}, fmt.Errorf("checkout.Update: list ancestor files: %w", err) } } return buildFileMaps(currentFiles, targetFiles, ancestorFiles), nil } // Update updates the checkout to a new version, performing 3-way merge where // needed to preserve local modifications. // // If opts.TargetRID is 0, CalcUpdateVersion is used to find the target. // If the target equals the current version, Update returns nil (nothing to do). // // Panics if c is nil (TigerStyle precondition). func (c *Checkout) Update(opts UpdateOpts) error { if c == nil { panic("checkout.Update: nil *Checkout") } // Determine target target := opts.TargetRID if target == 0 { var err error target, err = c.CalcUpdateVersion() if err != nil { return fmt.Errorf("checkout.Update: %w", err) } if target == 0 { return nil // nothing to update } } // Get current version currentRID, _, err := c.Version() if err != nil { return fmt.Errorf("checkout.Update: %w", err) } if currentRID == target { return nil // already at target } // Start observer ctx := c.obs.ExtractStarted(context.Background(), ExtractStart{ Operation: "update", TargetRID: target, }) var filesWritten, filesRemoved, conflicts int var updateErr error defer func() { c.obs.ExtractCompleted(ctx, ExtractEnd{ Operation: "update", TargetRID: target, FilesWritten: filesWritten, FilesRemoved: filesRemoved, Conflicts: conflicts, Err: updateErr, }) }() // Build 3-version file maps maps, err := c.buildUpdateMaps(currentRID, target) if err != nil { updateErr = err return updateErr } strategy, ok := merge.StrategyByName("three-way") if !ok { updateErr = fmt.Errorf("checkout.Update: three-way merge strategy not registered") return updateErr } // Look up checkin timestamp for SetMTime. var checkinTime time.Time if opts.SetMTime && !opts.DryRun { var mtimeRaw any if err := c.repo.DB().QueryRow( "SELECT mtime FROM event WHERE objid = ? AND type = 'ci'", int64(target), ).Scan(&mtimeRaw); err == nil { checkinTime, _ = db.ScanTime(mtimeRaw) } } // Process each file filesWritten, filesRemoved, conflicts, updateErr = c.processFileUpdates(ctx, maps, strategy, opts, checkinTime) if updateErr != nil { return updateErr } // Finalize: reload vfile and update vvar updateErr = c.finalizeUpdate(target) return updateErr } // finalizeUpdate reloads vfile for the target version, looks up its UUID, // and updates the vvar checkout/checkout-hash entries. func (c *Checkout) finalizeUpdate(target libfossil.FslID) error { if _, err := c.LoadVFile(target, true); err != nil { return fmt.Errorf("checkout.Update: reload vfile: %w", err) } var targetUUID string err := c.repo.DB().QueryRow("SELECT uuid FROM blob WHERE rid = ?", int64(target)).Scan(&targetUUID) if err != nil { return fmt.Errorf("checkout.Update: query target uuid: %w", err) } if err := setVVar(c.db, "checkout", strconv.FormatInt(int64(target), 10)); err != nil { return fmt.Errorf("checkout.Update: %w", err) } if err := setVVar(c.db, "checkout-hash", targetUUID); err != nil { return fmt.Errorf("checkout.Update: %w", err) } return nil } // updateFile handles the update logic for a single file across three versions. // Returns the UpdateChange type applied, or UpdateNone if the file was skipped. func (c *Checkout) updateFile( name string, curUUID, tgtUUID, ancUUID string, inCurrent, inTarget bool, strategy merge.Strategy, dryRun bool, ) (UpdateChange, error) { switch { case !inCurrent && inTarget: // New file in target — extract and write if !dryRun { if err := c.writeFileFromUUID(name, tgtUUID); err != nil { return UpdateNone, fmt.Errorf("add %s: %w", name, err) } } return UpdateAdded, nil case inCurrent && !inTarget: // File removed in target — delete from disk if !dryRun { fullPath, err := c.safePath(name) if err != nil { return UpdateNone, fmt.Errorf("remove %s: %w", name, err) } if err := c.env.Storage.Remove(fullPath); err != nil { // Ignore errors if file already missing if !os.IsNotExist(err) { return UpdateNone, fmt.Errorf("remove %s: %w", name, err) } } } return UpdateRemoved, nil case inCurrent && inTarget: if curUUID == tgtUUID { // Same content — skip return UpdateNone, nil } // Different content — determine who changed if curUUID == ancUUID { // Current matches ancestor, target changed — fast-forward if !dryRun { if err := c.writeFileFromUUID(name, tgtUUID); err != nil { return UpdateNone, fmt.Errorf("update %s: %w", name, err) } } return UpdateUpdated, nil } if tgtUUID == ancUUID { // Target matches ancestor, current changed — keep current return UpdateNone, nil } // Both changed — 3-way merge needed return c.mergeFile(name, ancUUID, curUUID, tgtUUID, strategy, dryRun) default: // Not in current, not in target — impossible given allNames construction return UpdateNone, nil } } // mergeFile performs a 3-way merge of a file that was modified in both current and target. func (c *Checkout) mergeFile( name, ancUUID, curUUID, tgtUUID string, strategy merge.Strategy, dryRun bool, ) (UpdateChange, error) { // Expand all three versions ancData, err := c.expandUUID(ancUUID) if err != nil { return UpdateNone, fmt.Errorf("merge %s: expand ancestor: %w", name, err) } curData, err := c.expandUUID(curUUID) if err != nil { return UpdateNone, fmt.Errorf("merge %s: expand current: %w", name, err) } tgtData, err := c.expandUUID(tgtUUID) if err != nil { return UpdateNone, fmt.Errorf("merge %s: expand target: %w", name, err) } result, err := strategy.Merge(ancData, curData, tgtData) if err != nil { return UpdateNone, fmt.Errorf("merge %s: %w", name, err) } if !dryRun { fullPath, err := c.safePath(name) if err != nil { return UpdateNone, fmt.Errorf("merge %s: path traversal: %w", name, err) } parentDir := filepath.Dir(fullPath) if err := c.env.Storage.MkdirAll(parentDir, os.FileMode(0o755)); err != nil { return UpdateNone, fmt.Errorf("merge %s: mkdir: %w", name, err) } if err := c.env.Storage.WriteFile(fullPath, result.Content, os.FileMode(0o644)); err != nil { return UpdateNone, fmt.Errorf("merge %s: write: %w", name, err) } } if result.Clean { return UpdateMerged, nil } return UpdateConflictMerged, nil } // writeFileFromUUID expands a blob by UUID and writes it to the checkout directory. func (c *Checkout) writeFileFromUUID(name, uuid string) error { data, err := c.expandUUID(uuid) if err != nil { return fmt.Errorf("writeFileFromUUID %s: %w", name, err) } fullPath, err := c.safePath(name) if err != nil { return fmt.Errorf("writeFileFromUUID %s: path traversal: %w", name, err) } parentDir := filepath.Dir(fullPath) if err := c.env.Storage.MkdirAll(parentDir, os.FileMode(0o755)); err != nil { return fmt.Errorf("mkdir %s: %w", parentDir, err) } if err := c.env.Storage.WriteFile(fullPath, data, os.FileMode(0o644)); err != nil { return fmt.Errorf("write %s: %w", fullPath, err) } return nil } // expandUUID resolves a UUID to its expanded blob content. func (c *Checkout) expandUUID(uuid string) ([]byte, error) { rid, ok := content.AvailableByUUID(c.repo.DB(), uuid) if !ok { return nil, fmt.Errorf("blob not found for uuid %s", uuid) } data, err := content.Expand(c.repo.DB(), rid) if err != nil { return nil, fmt.Errorf("expand rid %d (uuid %s): %w", rid, uuid, err) } return data, nil }