Artifact e4c6cdcf2134353bb4cfba72f6dea8eb295089d7cfb40ea57f56191c09f97bf5:
- File
internal/checkout/checkin.go
— part of check-in
[6273a0750d]
at
2026-07-22 23:03:24
on branch trunk
— fix: correct go.mod module path to go-libfossil (#76) (#117)
* fix: correct go.mod module path to go-libfossil (#76)
The module directive still declared github.com/danmestas/libfossil after the repository was renamed to go-libfossil. pkg.go.dev/godoc scraping matches the declared module path against its hosting path, so the stale path broke SDK doc generation.
Rename the module path across all four in-repo modules (root, the two db/driver submodules, observer/otel, and docs/site) and every internal import, plus the release workflow, the doc-generation script, and the docs.
No tag has been published under the go-libfossil path yet, so the cross-module requires cannot resolve from the proxy. Add replace directives pointing the in-repo requires at their local directories so GOWORK=off builds and the CI matrix resolve without a published version. These replaces are ignored by downstream consumers and can be dropped once new-path tags are cut.
* Guard releases against replace directives; regenerate SDK docs
The go-libfossil rename bridges the in-repo modules with `replace` directives. Go refuses a main module that carries them, so a tag cut while they are present breaks `go install .../cmd/libfossil@<tag>` and every `@version` fetch of the driver submodules. Add a Release workflow step that fails the job before anything is published if any tracked go.mod still contains a replace directive, so the bridge is a CI-enforced TODO rather than a silent landmine. The step comment records what unblocks a release: re-tag the submodules with the corrected module paths, bump the root requires, then drop the replace blocks.
Regenerate the SDK reference docs. The previous pass only substituted URLs over the pre-existing generated files, leaving stale signatures (Repo.Timeline was still documented as taking LogOpts rather than TimelineOpts). Note that gomarkdoc must be run outside a git worktree — inside one it silently strips every source link.
Also reword the README rename note, which read as though the import path had not changed, and re-sort the ncruces require block. (user: [email protected], size: 13735) [annotate] [blame] [check-ins using]
package checkout import ( "context" "database/sql" "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 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, } } // 3. For unchanged files, load content from the repo for name, entry := range fileMap { if len(entry.Content) > 0 { continue // already have content (was changed) } 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, exactly as it would have before this function existed. // This function's job is fixing missed mode changes, not detecting stale // tracking — a commit that never touched the missing file must still // succeed. 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 os.IsNotExist(err) { 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 }