Artifact 3adba90bbba7ca9a64a336736f36723b067b74a2fb03779cc4f429a6ff653de4:
- File
internal/checkout/checkin.go
— part of check-in
[0351167ae5]
at
2026-07-23 10:41:36
on branch trunk
— fix: zero-length file commit panic and stale missing-file carry-forward (#128)
* fix: zero-length file commit panic and stale missing-file carry-forward
Two related bugs in the checkin path:
#68 - Committing a zero-length file panicked in blob.Store, which treated an empty artifact (a normal, well-known Fossil blob) as invalid input. Worse, manifest.Checkin's postcondition defer fired unconditionally during the panic's stack unwind and re-panicked with its own unrelated "manifestRid must be positive" message, masking the real cause. Fixed both: blob.Store now accepts zero-length (and nil) content, and Checkin's defer re-panics with the original panic value instead of asserting a postcondition that was never going to hold mid-unwind. Fixing the panic surfaced a second bug in the same family: buildCommitFiles used len(Content) > 0 to detect "already loaded from disk," which can't distinguish a genuinely empty file from one not yet loaded, so a newly added empty file failed with "no RID for unchanged file." Replaced the length sentinel with an explicit loaded-set.
#79 - A tracked file deleted from disk without Unmanage/Remove was silently carried forward into the new manifest with its last-committed content, rather than being detected. Verified against fossil 2.28: canonical fails a commit when a missing tracked file is in scope (either the implicit "commit everything" case, or explicitly named), but leaves an out-of-scope missing file alone. buildCommitFiles now stats every in-scope tracked file before treating it as unchanged and errors clearly, naming the file, when it's gone; a file outside the commit's scope (relevant only to an explicit Enqueue) is left untouched exactly as before. Updated the two existing tests that had documented the old silent-carry-forward behavior as deliberately deferred to this issue.
* fix: add CHANGELOG entry and unwrap-safe stat checks for #68/#79
Adds the CHANGELOG entries the #79 acceptance criteria required for a user-visible behavior change (missing tracked file now hard-fails a commit instead of silently carrying stale content forward) and for the #68 zero-length-file panic fix.
Also switches checkin.go's two os.IsNotExist(err) checks to errors.Is(err, os.ErrNotExist), which correctly unwraps the %w-wrapped os.ErrNotExist that MemStorage.Stat returns. Production was already correct (OSStorage.Stat returns a raw *PathError), but the tests were only exercising a generic fallback error branch instead of the intended "tracked file missing from disk" message. (user: [email protected], size: 15686) [annotate] [blame] [check-ins using]
package checkout import ( "context" "database/sql" "errors" "fmt" "os" "strconv" "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/manifest" ) // Enqueue adds files to the commit staging queue. If the queue is empty (nil), // all changed files are implicitly enqueued. Once Enqueue is called, only // explicitly enqueued files will be committed. func (c *Checkout) Enqueue(opts EnqueueOpts) error { if c == nil { panic("checkout.Enqueue: nil *Checkout") } if c.checkinQueue == nil { c.checkinQueue = make(map[string]bool) } for _, p := range opts.Paths { c.checkinQueue[p] = true if opts.Callback != nil { if err := opts.Callback(p); err != nil { return fmt.Errorf("checkout.Enqueue: callback for %s: %w", p, err) } } } return nil } // Dequeue removes files from the commit staging queue. If opts.Paths is empty, // clears the entire queue (restoring implicit all-files behavior). // Returns error for API consistency / future-proofing. func (c *Checkout) Dequeue(opts DequeueOpts) error { if c == nil { panic("checkout.Dequeue: nil *Checkout") } if len(opts.Paths) == 0 { c.checkinQueue = nil // dequeue all return nil } for _, p := range opts.Paths { delete(c.checkinQueue, p) } return nil } // IsEnqueued returns true if the named file will be included in the next commit. // If the queue is nil (never initialized), all changed files are implicitly enqueued. // If the queue exists but is empty (len == 0), nothing is enqueued. // Returns error for API consistency / future-proofing. func (c *Checkout) IsEnqueued(name string) (bool, error) { if c == nil { panic("checkout.IsEnqueued: nil *Checkout") } if c.checkinQueue == nil { return true, nil // nil queue = all changed files implicitly enqueued } return c.checkinQueue[name], nil } // DiscardQueue clears the commit staging queue, restoring implicit all-files behavior. // Returns error for API consistency / future-proofing. func (c *Checkout) DiscardQueue() error { if c == nil { panic("checkout.DiscardQueue: nil *Checkout") } c.checkinQueue = nil return nil } // vfileEntry holds a single vfile row for commit processing. type vfileCommitEntry struct { pathname string changed bool deleted bool rid int64 isexe bool } // collectVFileEntries queries vfile for all entries of the given version and // classifies them into changed, deleted, and all-entries map. func (c *Checkout) collectVFileEntries(vid libfossil.FslID) ( entries map[string]vfileCommitEntry, changedFiles []string, deletedFiles []string, err error, ) { rows, err := c.db.Query(` SELECT pathname, CAST(chnged AS INTEGER), CAST(deleted AS INTEGER), rid, CAST(isexe AS INTEGER) FROM vfile WHERE vid = ? `, int64(vid)) if err != nil { return nil, nil, nil, fmt.Errorf("checkout.Commit: query vfile: %w", err) } defer rows.Close() entries = make(map[string]vfileCommitEntry) for rows.Next() { var pathname string var chnged, deleted, isexe int var rid sql.NullInt64 if err := rows.Scan(&pathname, &chnged, &deleted, &rid, &isexe); err != nil { return nil, nil, nil, fmt.Errorf("checkout.Commit: scan vfile: %w", err) } entries[pathname] = vfileCommitEntry{ pathname: pathname, changed: chnged > 0, deleted: deleted > 0, rid: rid.Int64, isexe: isexe > 0, } if deleted > 0 { deletedFiles = append(deletedFiles, pathname) } else if chnged > 0 || rid.Int64 == 0 { // rid=0 means newly added (never committed) — always treat // as changed even if ScanChanges reset chnged to 0. changedFiles = append(changedFiles, pathname) } } if err := rows.Err(); err != nil { return nil, nil, nil, fmt.Errorf("checkout.Commit: vfile rows: %w", err) } return entries, changedFiles, deletedFiles, nil } // buildCommitFiles constructs the complete file list for a new checkin by // starting from the parent manifest, applying vfile changes (modified/deleted), // and loading unchanged file content from the repo. func (c *Checkout) buildCommitFiles( parentRID libfossil.FslID, vfEntries map[string]vfileCommitEntry, changedFiles []string, deletedFiles []string, shouldInclude func(string) bool, ) ([]manifest.File, error) { var parentFiles []manifest.FileEntry if parentRID != 0 { var err error parentFiles, err = manifest.ListFiles(c.repo, parentRID) if err != nil { return nil, fmt.Errorf("checkout.Commit: list parent files: %w", err) } } // Build a map of parent files (name → FileEntry for O(1) lookup) parentFileMap := make(map[string]manifest.FileEntry, len(parentFiles)) for _, pf := range parentFiles { parentFileMap[pf.Name] = pf } // Start from parent file set fileMap := make(map[string]manifest.File, len(parentFiles)) for _, pf := range parentFiles { fileMap[pf.Name] = manifest.File{ Name: pf.Name, Perm: pf.Perm, } } // 1. Remove deleted files for _, name := range deletedFiles { if shouldInclude(name) { delete(fileMap, name) } } // 2. Update changed files with new content from disk loadedFromDisk := make(map[string]bool, len(changedFiles)) for _, name := range changedFiles { if !shouldInclude(name) { continue } fullPath, err := c.safePath(name) if err != nil { return nil, fmt.Errorf("checkout.Commit: path traversal in %s: %w", name, err) } fileData, err := c.env.Storage.ReadFile(fullPath) if err != nil { return nil, fmt.Errorf("checkout.Commit: read %s: %w", name, err) } perm := "" if existing, ok := fileMap[name]; ok { perm = existing.Perm } // The executable marker is re-derived from the live on-disk mode by // restatCommitPerms below, for every included non-symlink file (not // only ones change scanning flagged) — so permission is decided // later, from the filesystem, rather than from vfile.isexe here. fileMap[name] = manifest.File{ Name: name, Content: fileData, Perm: perm, } loadedFromDisk[name] = true } // 3. For unchanged files, load content from the repo. // // loadedFromDisk (not len(entry.Content) > 0) is the sentinel for // "already handled in step 2" — a legitimately empty file's Content is // indistinguishable from "not yet loaded" by length alone, and using // length here mistook a newly-added zero-length file for one still // needing a blob lookup it doesn't have (issue #68). for name, entry := range fileMap { if loadedFromDisk[name] { continue } // A tracked file this commit is scoped to (shouldInclude) but that // is missing from disk cannot be silently carried forward with its // stale content — that would record a commit claiming content that // no longer exists (issue #79). A file outside this commit's scope // (relevant only for an explicit Enqueue) is left untouched exactly // as before: canonical Fossil only fails on a missing file that is // actually part of the commit being made. // // Only files with a live vfile row are checked — matching // restatCommitPerms and canonical Fossil's own vfile-driven manifest // loop. A name present only in the parent manifest (no vfEntries // row) isn't tracked by this checkout's vfile at all; there is // nothing to have "gone missing" from a local scan that never // covered it. if _, hasVFileRow := vfEntries[name]; hasVFileRow && shouldInclude(name) { fullPath, err := c.safePath(name) if err != nil { return nil, fmt.Errorf("checkout.Commit: path traversal in %s: %w", name, err) } if _, err := c.env.Storage.Stat(fullPath); err != nil { if errors.Is(err, os.ErrNotExist) { return nil, fmt.Errorf( "checkout.Commit: tracked file missing from disk: %s", name, ) } return nil, fmt.Errorf("checkout.Commit: stat %s: %w", name, err) } } var fileRID int64 if ve, ok := vfEntries[name]; ok { fileRID = ve.rid } else if pf, ok := parentFileMap[name]; ok { // File is in parent but not in vfile — look up RID by UUID var rid int64 err := c.repo.DB().QueryRow("SELECT rid FROM blob WHERE uuid = ?", pf.UUID).Scan(&rid) if err != nil { return nil, fmt.Errorf("checkout.Commit: resolve RID for %s: %w", name, err) } fileRID = rid } if fileRID == 0 { return nil, fmt.Errorf("checkout.Commit: no RID for unchanged file %s", name) } fileData, err := content.Expand(c.repo.DB(), libfossil.FslID(fileRID)) if err != nil { return nil, fmt.Errorf("checkout.Commit: expand %s: %w", name, err) } fileMap[name] = manifest.File{ Name: name, Content: fileData, Perm: entry.Perm, } } // 4. Independently re-stat every included file's on-disk executable bit, // belt-and-braces, regardless of whether change scanning flagged it. if err := c.restatCommitPerms(fileMap, vfEntries, shouldInclude); err != nil { return nil, err } // Convert to slice commitFiles := make([]manifest.File, 0, len(fileMap)) for _, f := range fileMap { commitFiles = append(commitFiles, f) } return commitFiles, nil } // restatCommitPerms re-derives each included file's executable permission // directly from its live on-disk mode, overriding whatever change scanning // captured earlier. This exists because change scanning compares content // hashes: a file whose only edit is a chmod has an unchanged hash, so // vfile.chnged/isexe can go stale and silently carry forward the wrong // permission. Manifest generation must consult the filesystem itself for // every file it is about to serialize — mirroring canonical Fossil's // belt-and-braces re-stat in src/checkin.c — rather than trust a cached // flag a second time. // // Only files that actually have a vfile row are considered — matching // canonical Fossil, whose manifest loop is driven by a query over vfile // (JOIN blob) rather than the union of parent-manifest and vfile names. // Files carried forward from the parent manifest but no longer present in // the local vfile (should not happen once LoadVFile has run, but is not an // invariant this function enforces) are left untouched, since there is no // live vfile row to justify a re-stat. Symlinks (Perm == "l") and files not // selected for this commit are also left untouched. On Windows, // os.FileMode never carries an owner-execute bit, so modeIsExecutable // always returns false and this is a no-op. // // A file that has gone missing from disk (deleted outside of Unmanage, or // otherwise untracked-but-not-told) is skipped, carrying its existing Perm // forward unchanged. This function's job is fixing missed mode changes, not // detecting stale tracking, so it stays lenient here — but that leniency is // safe because buildCommitFiles already rejected any in-scope missing file // before this function ever runs (issue #79); a missing file only reaches // here at all when it was out of scope (shouldInclude false), in which case // carrying its Perm forward unchanged is correct. Any other Stat error // (permission denied, I/O fault, ...) is a real problem and still fails // the commit. func (c *Checkout) restatCommitPerms( fileMap map[string]manifest.File, vfEntries map[string]vfileCommitEntry, shouldInclude func(string) bool, ) error { if fileMap == nil { panic("checkout.restatCommitPerms: fileMap must not be nil") } if shouldInclude == nil { panic("checkout.restatCommitPerms: shouldInclude must not be nil") } for name := range vfEntries { entry, ok := fileMap[name] if !ok { continue // deleted, or otherwise dropped from this commit } if entry.Perm == "l" { continue } if !shouldInclude(name) { continue } fullPath, err := c.safePath(name) if err != nil { return fmt.Errorf("checkout.Commit: path traversal in %s: %w", name, err) } info, err := c.env.Storage.Stat(fullPath) if err != nil { if errors.Is(err, os.ErrNotExist) { continue // missing on disk: carry the existing Perm forward unchanged } return fmt.Errorf("checkout.Commit: stat %s: %w", name, err) } if modeIsExecutable(info.Mode()) { entry.Perm = "x" } else { entry.Perm = "" } if entry.Perm != "" && entry.Perm != "x" { panic("checkout.restatCommitPerms: re-derived perm must be \"\" or \"x\"") } fileMap[name] = entry } return nil } // finalizeCommit updates vvar, reloads vfile, and clears the checkin queue // after a successful manifest.Checkin. func (c *Checkout) finalizeCommit(newRID libfossil.FslID, newUUID string) error { if err := setVVar(c.db, "checkout", strconv.FormatInt(int64(newRID), 10)); err != nil { return fmt.Errorf("checkout.Commit: set checkout vvar: %w", err) } if err := setVVar(c.db, "checkout-hash", newUUID); err != nil { return fmt.Errorf("checkout.Commit: set checkout-hash vvar: %w", err) } if _, err := c.LoadVFile(newRID, true); err != nil { return fmt.Errorf("checkout.Commit: reload vfile: %w", err) } c.checkinQueue = nil return nil } // Commit creates a new checkin from staged files in the checkout. // Returns the new manifest RID and UUID. func (c *Checkout) Commit(opts CommitOpts) (libfossil.FslID, string, error) { if c == nil { panic("checkout.Commit: nil *Checkout") } parentRID, _, err := c.Version() if err != nil { return 0, "", fmt.Errorf("checkout.Commit: %w", err) } if err := c.ScanChanges(ScanHash); err != nil { return 0, "", fmt.Errorf("checkout.Commit: scan: %w", err) } if opts.PreCommitCheck != nil { if err := opts.PreCommitCheck(); err != nil { return 0, "", fmt.Errorf("checkout.Commit: pre-commit check: %w", err) } } vfEntries, changedFiles, deletedFiles, err := c.collectVFileEntries(parentRID) if err != nil { return 0, "", err } queueActive := c.checkinQueue != nil && len(c.checkinQueue) > 0 shouldInclude := func(name string) bool { if !queueActive { return true } return c.checkinQueue[name] } enqueuedCount := len(changedFiles) if queueActive { enqueuedCount = len(c.checkinQueue) } ctx := c.obs.CommitStarted(context.Background(), CommitStart{ FilesEnqueued: enqueuedCount, Branch: opts.Branch, User: opts.User, }) var result CommitEnd defer func() { c.obs.CommitCompleted(ctx, result) }() commitFiles, err := c.buildCommitFiles( parentRID, vfEntries, changedFiles, deletedFiles, shouldInclude, ) if err != nil { result.Err = err return 0, "", err } commitTime := opts.Time if commitTime.IsZero() { commitTime = c.env.Clock.Now() } // Build T-cards from CommitOpts.Branch and Tags. var tagCards []deck.TagCard if opts.Branch != "" { tagCards = append(tagCards, deck.TagCard{ Type: deck.TagPropagating, Name: "branch", UUID: "*", Value: opts.Branch, }) tagCards = append(tagCards, deck.TagCard{ Type: deck.TagSingleton, Name: "sym-" + opts.Branch, UUID: "*", }) } for _, t := range opts.Tags { tagCards = append(tagCards, deck.TagCard{ Type: deck.TagSingleton, Name: t, UUID: "*", }) } checkinOpts := manifest.CheckinOpts{ Files: commitFiles, Comment: opts.Message, User: opts.User, Parent: parentRID, Time: commitTime, Delta: opts.Delta, } if len(tagCards) > 0 { checkinOpts.Tags = tagCards } newRID, newUUID, err := manifest.Checkin(c.repo, checkinOpts) if err != nil { result.Err = fmt.Errorf("checkout.Commit: checkin: %w", err) return 0, "", result.Err } if err := c.finalizeCommit(newRID, newUUID); err != nil { result.Err = err return 0, "", err } result.RID = newRID result.UUID = newUUID result.FilesCommit = len(commitFiles) return newRID, newUUID, nil }