Artifact 9629804c6f3094ef0608493a88a210a8bd84fea84a8758a3cc4a5bc9c9aed544:
- File
internal/checkout/revert.go
— part of check-in
[de8f3ed302]
at
2026-10-03 17:09:38
on branch trunk
— fix(cli): add, rm, rename and revert call the library (#234) (#245)
* fix(cli): add, rm, rename and revert call the library (#234)
These commands wrote the checkout's vfile with raw SQL, each with its own copy of rules the library already gets right, and ignored most Exec errors. rename recorded the new name but left the file under the old one, so the next commit failed on a "missing" tracked file. revert only cleared change flags over an edited file and restored nothing. add wrote a SHA1 mhash whatever the repo's hash policy.
They now open the checkout through the library (openWorkingCheckout, which finds the repository from the checkout when -R is not given) and call Add, Remove, Rename and Revert, returning every error, including from closing. rename moves the file, like fossil mv --hard; revert restores committed content, like fossil revert.
Two library gaps the CLI exposed are fixed where they live, matching fossil's revert: - Revert refreshes change flags from disk before choosing files, so an edit no scan has recorded yet is reverted instead of skipped. - Reverting a never-committed add un-manages it and leaves the file on disk. It used to delete the file, destroying the only copy; fossil prints UNMANAGE and keeps it.
revertFile's restore path moves to restoreCommitted, bringing it under 70 lines. The merge, merge-resolve and conflicts commands are #244.
* docs(changelog): note the CLI and Revert changes (#234)
* fix(checkout,cli): revert and rename match fossil; find repo without -R (#234)
Review of the CLI switch found the library and the new helper falling short of fossil:
- Without -R, every command failed on a fresh fossil checkout: the repository lookup opened the checkout database read-only, which clashes with the WAL pragma on a database fossil left in delete journal mode, and hid the real error. It now opens it as the library does, reports the error, and resolves a relative repository path, as fossil open stores it, against the checkout's directory. - Rename moved the file onto an existing untracked file, destroying it. It now refuses, as fossil mv --hard does, before changing anything. - Revert skipped a file missing from disk (no change flag records it) while the CLI reported it reverted. A named file is now reverted whatever its flags say, and revert-all includes missing files. - Revert left merge state behind: restored rows kept mrid on the merged-in version and vmerge kept its row, so the next scan saw the file changed and the next commit recorded a merge parent. Restored rows now reset mrid and mhash, and revert-all clears vmerge, as fossil's revert does.
Rename's vfile lookups move to renameRowID and revert-all's selection to needsRevert, keeping both functions under 70 lines. The re-add test now checks the vfile row itself, since fossil's own scan would hide a stray chnged=1.
* fix(checkout): revert removes a file a merge added, as fossil does (#234)
Revert decided between restore and un-manage by rid alone. A file a merge added has a non-zero rid (its merged-in artifact) but is not in the checked-out version, so revert restored it as if committed and left it tracked; the next commit then included it. Fossil looks the file up in the version's manifest and, when it is absent and rid is not 0, deletes the file and its row (its content lives on in the merged-in version).
revertFile now takes the vfile row and the version's path set and does the same: a pending add is un-managed and kept on disk; a file the version lacks, under its name or its name before a pending rename, is removed; anything else gets its committed content back. Checking the pre-rename name keeps a renamed, edited file from being deleted.
Rename's refusal message now fits a directory at the destination too.
* fix(checkout): never remove a non-file when reverting a merge add (#234)
removeMergeAdded deletes only a regular file at the merge-added path; anything else there, such as a directory the user made, is left alone. Revert's doc now says edits to reverted files are discarded: fossil saves an undo copy first, go-libfossil has no undo yet. (user: [email protected], size: 8148) [annotate] [blame] [check-ins using]
package checkout import ( "errors" "fmt" "os" "path/filepath" "github.com/danmestas/go-libfossil/internal/content" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" "github.com/danmestas/go-libfossil/internal/manifest" "github.com/danmestas/go-libfossil/internal/vfile" ) // Revert restores files to their checkout version state, the way fossil's // revert does. Edits to the reverted files are discarded: fossil saves an undo // copy first, but go-libfossil has no undo yet, so they are gone. If opts.Paths is empty, reverts ALL changed files. Change // flags are refreshed from disk first, so an edit not yet scanned is // reverted too. // // For each file to revert: // - If rid==0 (newly added): un-manage it (DELETE from vfile). The file stays // on disk: it was never committed, so deleting it would destroy the only copy. // - If rid>0 (existing, modified/deleted): restore original content, reset chnged=0, deleted=0 // // Panics if c is nil (TigerStyle precondition). func (c *Checkout) Revert(opts RevertOpts) error { if c == nil { panic("checkout.Revert: nil *Checkout") } // Get current version vid, _, err := c.Version() if err != nil { return fmt.Errorf("checkout.Revert: %w", err) } if vid > 0 { if err := c.refreshChanged(vid); err != nil { return fmt.Errorf("checkout.Revert: %w", err) } } if len(opts.Paths) > 0 { for _, path := range opts.Paths { if err := c.revertSinglePath(vid, path, opts.Callback); err != nil { return fmt.Errorf("checkout.Revert: %w", err) } } return nil } if err := c.revertAll(vid, opts.Callback); err != nil { return fmt.Errorf("checkout.Revert: %w", err) } return nil } // revertAll reverts every changed file of version vid and drops any pending // merge, as fossil's revert does with no file named. A file is reverted when // its content changed, it is removed, an add is pending, a merge touched it, // or it is missing from disk. A pure rename is left for #242: revertFile // cannot move a file back to its old name yet. func (c *Checkout) revertAll(vid libfossil.FslID, callback func(string, RevertChange) error) error { if vid < 0 { panic("checkout.revertAll: negative vid") } rows, err := vfile.Load(c.db, int64(vid)) if err != nil { return err } inVersion, err := c.versionPaths(vid) if err != nil { return err } for _, r := range rows { missing, err := c.isMissing(r) if err != nil { return err } if !needsRevert(r, missing) { continue } if err := c.revertFile(r, inVersion, callback); err != nil { return err } } if _, err := c.db.Exec("DELETE FROM vmerge"); err != nil { return fmt.Errorf("clear vmerge: %w", err) } return nil } // needsRevert reports whether revert-all restores row r: its content // changed, it is removed, a merge touched it, or it is missing from disk. func needsRevert(r vfile.Row, missing bool) bool { if missing { return true } if r.ContentChanged() { return true } if r.IsRemoved() { return true } return r.PendingMerge() } // revertSinglePath reverts a named file whatever its change flags say, as // fossil's revert does for a file it is given: a file missing from disk is // restored too. A path the checkout does not track is a no-op, as in fossil. func (c *Checkout) revertSinglePath( vid libfossil.FslID, pathname string, callback func(string, RevertChange) error, ) error { rows, err := vfile.Load(c.db, int64(vid)) if err != nil { return err } for _, r := range rows { if r.Pathname != pathname { continue } inVersion, err := c.versionPaths(vid) if err != nil { return err } return c.revertFile(r, inVersion, callback) } return nil } // versionPaths returns the set of pathnames in checked-out version vid's // manifest; empty when nothing is checked out yet. func (c *Checkout) versionPaths(vid libfossil.FslID) (map[string]bool, error) { paths := make(map[string]bool) if vid <= 0 { return paths, nil } files, err := manifest.ListFiles(c.repo, vid) if err != nil { return nil, fmt.Errorf("list version files: %w", err) } for _, f := range files { paths[f.Name] = true } return paths, nil } // revertFile reverts one row the way fossil's revert does, by whether the // checked-out version has the file (under its name, or its name before a // pending rename): // - a pending add (rid=0) is un-managed and left on disk: there is no // committed copy, so deleting it would destroy the only one; // - a file the version lacks but the checkout tracks (one a merge added) // is deleted with its row: its content lives on in the merged-in version; // - anything else gets its committed content back. func (c *Checkout) revertFile( r vfile.Row, inVersion map[string]bool, callback func(string, RevertChange) error, ) error { if inVersion == nil { panic("checkout.revertFile: nil inVersion") } fullPath, err := c.safePath(r.Pathname) if err != nil { return fmt.Errorf("checkout.Revert: path traversal in %s: %w", r.Pathname, err) } change := RevertContents switch { case r.IsAdded(): change = RevertUnmanage if _, err := c.db.Exec("DELETE FROM vfile WHERE id = ?", r.ID); err != nil { return fmt.Errorf("checkout.Revert: delete vfile for %s: %w", r.Pathname, err) } case !inVersion[r.Pathname] && !inVersion[r.RenamedFrom()]: change = RevertRemove if err := c.removeMergeAdded(r, fullPath); err != nil { return err } default: if err := c.restoreCommitted(r.ID, r.Pathname, fullPath, r.RID); err != nil { return err } } if callback != nil { if err := callback(r.Pathname, change); err != nil { return fmt.Errorf("checkout.Revert: callback for %s: %w", r.Pathname, err) } } return nil } // removeMergeAdded deletes a file the checked-out version does not have, // from disk and from vfile. func (c *Checkout) removeMergeAdded(r vfile.Row, fullPath string) error { if r.RID <= 0 { panic("checkout.removeMergeAdded: pending add has no merged-in content") } // Only a regular file is the merge's: anything else at that path (a // directory the user made) is left alone. info, err := c.env.Storage.Stat(fullPath) if err != nil && !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("checkout.Revert: stat %s: %w", fullPath, err) } if err == nil && info.Mode().IsRegular() { if err := c.env.Storage.Remove(fullPath); err != nil { return fmt.Errorf("checkout.Revert: remove %s: %w", fullPath, err) } } if _, err := c.db.Exec("DELETE FROM vfile WHERE id = ?", r.ID); err != nil { return fmt.Errorf("checkout.Revert: delete vfile for %s: %w", r.Pathname, err) } return nil } // restoreCommitted writes the committed content of blob rid back to fullPath, // with the row's executable bit, and marks row id unchanged and unmerged. func (c *Checkout) restoreCommitted(id int64, pathname, fullPath string, rid int64) error { if rid <= 0 { panic("checkout.restoreCommitted: rid must be positive") } if fullPath == "" { panic("checkout.restoreCommitted: empty fullPath") } data, err := content.Expand(c.repo.DB(), libfossil.FslID(rid)) if err != nil { return fmt.Errorf("checkout.Revert: expand blob for %s: %w", pathname, err) } var isexe int64 err = c.db.QueryRow("SELECT CAST(isexe AS INTEGER) FROM vfile WHERE id = ?", id).Scan(&isexe) if err != nil { return fmt.Errorf("checkout.Revert: query vfile metadata for %s: %w", pathname, err) } parentDir := filepath.Dir(fullPath) if err := c.env.Storage.MkdirAll(parentDir, 0o755); err != nil { return fmt.Errorf("checkout.Revert: mkdir %s: %w", parentDir, err) } perm := os.FileMode(0o644) if isexe != 0 { perm = 0o755 } if err := c.env.Storage.WriteFile(fullPath, data, perm); err != nil { return fmt.Errorf("checkout.Revert: write %s: %w", fullPath, err) } // mrid and mhash go back to the committed version, so no merged-in // content outlives the revert (fossil resets them the same way). if _, err := c.db.Exec( "UPDATE vfile SET chnged = 0, deleted = 0, mrid = rid, mhash = NULL WHERE id = ?", id, ); err != nil { return fmt.Errorf("checkout.Revert: update vfile for %s: %w", pathname, err) } return nil }