Artifact 5ce8b2f818316399650d84833ff14b166030534745c20004f8dcf4ebc77daa48:
- File
internal/checkout/extract.go
— part of check-in
[858c20561f]
at
2026-10-19 22:58:21
on branch feat/submanifest-reader
— fix(undo): save and restore symlinks as fossil's undo does
Move checkout's link-aware diskView into vfile.Disk, so checkout, undo and stash share one RepoFILE view: Stat, Read, Write (which deletes a link before writing, makes a symlink when allow-symlinks is on, creates the directory and sets the executable bit as file_setexe does), SetExe, MTime and Remove. The allow-symlinks setting is read by vfile.OpenDisk.
undo.Save, Undo, Redo, Swap, ReadFile and RestoreFiles now take a Disk: a symlink is read as its target's name and recorded with isLink, and is deleted and made a symlink again on restore, as undo_save and undo_one do, so Undo of an Update no longer writes through a link. (user: [email protected], size: 13287) [annotate] [blame] [check-ins using] [more...]
package checkout import ( "context" "fmt" "os" "strconv" "strings" "github.com/danmestas/go-libfossil/internal/content" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" "github.com/danmestas/go-libfossil/internal/hash" "github.com/danmestas/go-libfossil/internal/manifest" "github.com/danmestas/go-libfossil/internal/vfile" ) // extractSingleFile expands a blob and writes it to the checkout directory, // a symlink entry as fossil writes it (vfile.Disk.Write), returning the // vfile.mtime to record for it. Skips writing if dryRun is true. func (c *Checkout) extractSingleFile( pathname string, blobRid int64, isexe, islink int64, dryRun bool, ) (int64, error) { if dryRun { return 0, nil } data, err := content.Expand(c.repo.DB(), libfossil.FslID(blobRid)) if err != nil { return 0, fmt.Errorf("checkout.Extract: expand blob for %s: %w", pathname, err) } fullPath, err := c.safePath(pathname) if err != nil { return 0, fmt.Errorf("checkout.Extract: path traversal in %s: %w", pathname, err) } disk, err := c.newDiskView() if err != nil { return 0, fmt.Errorf("checkout.Extract: %w", err) } if err := disk.Write(fullPath, data, isexe != 0, islink != 0); err != nil { return 0, fmt.Errorf("checkout.Extract: %w", err) } mtime, err := disk.MTime(fullPath) if err != nil { return 0, fmt.Errorf("checkout.Extract: %w", err) } return mtime, nil } // Extract writes files from the specified checkin to disk via simio.Storage. // Populates vfile, updates vvar checkout/checkout-hash to rid. // // If opts.DryRun is true, skips writing files but still calls observer and callback. // If opts.Force is false (default), refuses before changing anything when the // current version has unsaved changes or an untracked file would be // overwritten (see checkSafeToExtract). // // Panics if c is nil (TigerStyle precondition). func (c *Checkout) Extract(rid libfossil.FslID, opts ExtractOpts) error { if c == nil { panic("checkout.Extract: nil *Checkout") } // Start observer ctx := c.obs.ExtractStarted(context.Background(), ExtractStart{ Operation: "extract", TargetRID: rid, }) var filesWritten int var extractErr error defer func() { c.obs.ExtractCompleted(ctx, ExtractEnd{ Operation: "extract", TargetRID: rid, FilesWritten: filesWritten, Err: extractErr, }) }() // Check before LoadVFile: it replaces the current version's rows, and a // refusal must leave the checkout exactly as it was. if !opts.Force && !opts.DryRun { if err := c.checkSafeToExtract(rid); err != nil { extractErr = err return extractErr } } // The files come from the target's manifest, so a dry run never has to // load them into vfile; only a real extract replaces the file list. vfRows, err := c.extractRows(rid) if err != nil { extractErr = err return extractErr } if !opts.DryRun { if err := c.replaceFileList(rid, opts.Force); err != nil { extractErr = err return extractErr } } for _, row := range vfRows { if err := c.extractRow(ctx, rid, row, opts); err != nil { extractErr = err return extractErr } filesWritten++ } if opts.DryRun { return nil } if extractErr = c.finalizeExtract(rid, opts.Force); extractErr != nil { return extractErr } if opts.SetMTime { extractErr = c.setFileMTimes(rid) } return extractErr } // finalizeExtract looks up the blob UUID for rid and updates the vvar // checkout/checkout-hash entries. // It also ends a pending merge on a switch to another version or a forced // extract, but not on a plain re-extract of the version already checked out, // which fossil's checkout does not treat as a switch. func (c *Checkout) finalizeExtract(rid libfossil.FslID, force bool) error { prior, _, err := c.Version() if err != nil { return fmt.Errorf("checkout.Extract: %w", err) } endMerge := force || prior != rid var uuid string err = c.repo.DB().QueryRow("SELECT uuid FROM blob WHERE rid = ?", int64(rid)).Scan(&uuid) if err != nil { return fmt.Errorf("checkout.Extract: query blob uuid: %w", err) } if err := setVVar(c.db, "checkout", strconv.FormatInt(int64(rid), 10)); err != nil { return fmt.Errorf("checkout.Extract: %w", err) } if err := setVVar(c.db, "checkout-hash", uuid); err != nil { return fmt.Errorf("checkout.Extract: %w", err) } if !endMerge { return nil } // A switch ends any pending merge, as fossil's checkout does; a stale // vmerge row would give the next commit a bogus merge parent. if _, err := c.db.Exec("DELETE FROM vmerge"); err != nil { return fmt.Errorf("checkout.Extract: clear vmerge: %w", err) } return nil } // replaceFileList loads version rid into vfile. A forced extract first drops // every row, the current version's included, as fossil's checkout --force // does, so no pending add, rename or removal survives it; otherwise rows the // version already has are kept (LoadVFile). func (c *Checkout) replaceFileList(rid libfossil.FslID, force bool) error { if rid <= 0 { panic("checkout.replaceFileList: rid must be positive") } if force { if _, err := c.db.Exec("DELETE FROM vfile"); err != nil { return fmt.Errorf("checkout.Extract: clear vfile: %w", err) } } if err := c.LoadVFile(rid, true); err != nil { return fmt.Errorf("checkout.Extract: %w", err) } return nil } // extractRow is one vfile row Extract writes to disk. type extractRow struct { pathname string blobRid int64 isexe int64 islink int64 } // extractRows lists version rid's files the cone covers -- every file of a // dense checkout -- with the blob each one's content is in, from its // manifest. It refuses a version whose content is missing (resolveFiles), // before anything is written. func (c *Checkout) extractRows(rid libfossil.FslID) ([]extractRow, error) { if rid <= 0 { panic("checkout.extractRows: rid must be positive") } files, blobRIDs, err := c.resolveFiles(rid, c.fileFilter()) if err != nil { return nil, fmt.Errorf("checkout.Extract: %w", err) } out := make([]extractRow, len(files)) for i, f := range files { row := extractRow{pathname: f.Name, blobRid: int64(blobRIDs[i])} if strings.Contains(f.Perm, "x") { row.isexe = 1 } if strings.Contains(f.Perm, "l") { row.islink = 1 } out[i] = row } return out, nil } // extractRow writes one file of version vid, records the mtime the write // gave it, and reports it to the observer and the caller's callback. func (c *Checkout) extractRow( ctx context.Context, vid libfossil.FslID, row extractRow, opts ExtractOpts, ) error { mtime, err := c.extractSingleFile(row.pathname, row.blobRid, row.isexe, row.islink, opts.DryRun) if err != nil { return err } if !opts.DryRun { if err := c.recordMTime(c.db, vid, row.pathname, row.blobRid, mtime); err != nil { return fmt.Errorf("checkout.Extract: %w", err) } // The file now holds the content its row names, so an edit or // missing file the row recorded is gone. if _, err := c.db.Exec( "UPDATE vfile SET chnged = 0 WHERE vid = ? AND pathname = ? AND mrid = ? AND chnged = 1", int64(vid), row.pathname, row.blobRid, ); err != nil { return fmt.Errorf("checkout.Extract: reset %s: %w", row.pathname, err) } } c.obs.ExtractFileCompleted(ctx, row.pathname, UpdateAdded) if opts.Callback != nil { if err := opts.Callback(row.pathname, UpdateAdded); err != nil { return fmt.Errorf("checkout.Extract: callback for %s: %w", row.pathname, err) } } return nil } // checkSafeToExtract refuses an Extract that would destroy work the user has // not committed. It follows fossil's checkout command, which refuses while the // current version has unsaved changes (edits, adds, removals, renames, // merges) and prompts before overwriting a file it does not track; a library // cannot prompt, so it refuses. It runs before anything in the checkout // changes. // // One relaxation: an edit whose content already equals what target would // write loses nothing, so it does not block. That matters because Create // records the tip as checked out before any file is written, so a directory // already holding an older version looks edited relative to the tip. func (c *Checkout) checkSafeToExtract(target libfossil.FslID) error { if target <= 0 { panic("checkout.checkSafeToExtract: target must be positive") } current, _, err := c.Version() if err != nil { return fmt.Errorf("checkout.Extract: %w", err) } files, err := manifest.ListFilesFiltered(c.repo, target, c.fileFilter()) if err != nil { return fmt.Errorf("checkout.Extract: %w", err) } targetHash := make(map[string]string, len(files)) for _, f := range files { targetHash[f.Name] = f.UUID } if current > 0 { atRisk, err := c.firstChangeAtRisk(current, targetHash) if err != nil { return fmt.Errorf("checkout.Extract: %w", err) } if atRisk != "" { return fmt.Errorf( "checkout.Extract: file %s has local changes; use Force to overwrite", atRisk, ) } } return c.checkNoUntrackedClobber(current, files) } // firstChangeAtRisk scans version vid and returns the first changed file // whose change extracting target would lose, or "" when there is none. func (c *Checkout) firstChangeAtRisk( vid libfossil.FslID, targetHash map[string]string, ) (string, error) { if vid <= 0 { panic("checkout.firstChangeAtRisk: vid must be positive") } if targetHash == nil { panic("checkout.firstChangeAtRisk: nil targetHash") } if err := c.refreshChanged(vid); err != nil { return "", err } // A pending merge is unsaved work in its own right: the vmerge record // would be dropped even if every merged file already matches target. var merging bool if err := c.db.QueryRow( "SELECT EXISTS(SELECT 1 FROM vmerge)", ).Scan(&merging); err != nil { return "", fmt.Errorf("query vmerge: %w", err) } rows, err := c.managedRows(vid) if err != nil { return "", err } for _, r := range rows { change := vfile.Classify(r, false) if change == ChangeNone { continue } // The scan marks a missing file changed, and a missing symlink // entry unlinked, as fossil's does, but nothing on disk is lost by // writing it; Create leaves every file missing until the first // Extract, which plays fossil's open. missing, err := c.isMissing(r) if err != nil { return "", err } if missing && isEditOrMode(change) && !r.PendingMerge() { continue } // Only a plain edit (no merge pending, not a merge state) of a file // target also writes can be safe; its content decides. if !merging && change == ChangeModified && !r.PendingMerge() && !r.ModeChanged() { if uuid, ok := targetHash[r.Pathname]; ok { clobber, err := c.wouldClobber(r.Pathname, uuid) if err != nil { return "", err } if !clobber { continue } } } return r.Pathname, nil } return "", nil } // isEditOrMode reports a change to a file's content or mode alone: not an // add, removal or rename. func isEditOrMode(change FileChange) bool { switch change { case ChangeModified, ChangeExecutable, ChangeSymlink, ChangeUnexec, ChangeUnlink: return true } return false } // wouldClobber reports whether writing the artifact uuid to pathname would // destroy content: true only when a file is there and differs from it. func (c *Checkout) wouldClobber(pathname, uuid string) (bool, error) { if !hash.IsValidHash(uuid) { panic("checkout.wouldClobber: invalid uuid for " + pathname) } fullPath, err := c.safePath(pathname) if err != nil { return false, nil // Extraction rejects the path itself, with a better error. } data, err := c.env.Storage.ReadFile(fullPath) if err != nil { if os.IsNotExist(err) { return false, nil } return false, fmt.Errorf("checkout.Extract: read %s: %w", fullPath, err) } return hash.ContentHash(data, uuid) != uuid, nil } // checkNoUntrackedClobber returns an error if a file that version current // does not track exists on disk where target would write different content. // current may be 0 (nothing checked out yet). func (c *Checkout) checkNoUntrackedClobber( current libfossil.FslID, files []manifest.FileEntry, ) error { if current < 0 { panic("checkout.checkNoUntrackedClobber: negative current") } tracked, err := c.trackedPaths(current) if err != nil { return err } for _, f := range files { if tracked[f.Name] { continue } clobber, err := c.wouldClobber(f.Name, f.UUID) if err != nil { return err } if clobber { return fmt.Errorf( "checkout.Extract: untracked file %s has local changes; use Force to overwrite", f.Name, ) } } return nil } // trackedPaths returns the set of pathnames vfile holds for version vid. func (c *Checkout) trackedPaths(vid libfossil.FslID) (map[string]bool, error) { rows, err := c.db.Query("SELECT pathname FROM vfile WHERE vid = ?", int64(vid)) if err != nil { return nil, fmt.Errorf("checkout.Extract: query vfile: %w", err) } defer rows.Close() tracked := make(map[string]bool) for rows.Next() { var name string if err := rows.Scan(&name); err != nil { return nil, fmt.Errorf("checkout.Extract: scan vfile: %w", err) } tracked[name] = true } if err := rows.Err(); err != nil { return nil, fmt.Errorf("checkout.Extract: iterate vfile: %w", err) } return tracked, nil }