Artifact ff3665a2e5b29620362791baf0ab879db94cb9c8c03d2f9274cb814b5199bbab:
- File
internal/checkout/rename.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: 7908) [annotate] [blame] [check-ins using]
package checkout import ( "database/sql" "errors" "fmt" "os" "path/filepath" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" ) // moveFile reads a file from oldPath, writes it to newPath with the given // permissions, and removes the original. Ignores NotExist on remove. func (c *Checkout) moveFile(oldPath, newPath string, perm os.FileMode) error { data, err := c.env.Storage.ReadFile(oldPath) if err != nil { return fmt.Errorf("read %s: %w", oldPath, err) } newParentDir := filepath.Dir(newPath) if err := c.env.Storage.MkdirAll(newParentDir, 0o755); err != nil { return fmt.Errorf("mkdir %s: %w", newParentDir, err) } if err := c.env.Storage.WriteFile(newPath, data, perm); err != nil { return fmt.Errorf("write %s: %w", newPath, err) } if err := c.env.Storage.Remove(oldPath); err != nil && !os.IsNotExist(err) { return fmt.Errorf("remove %s: %w", oldPath, err) } return nil } // vfilePerm queries the isexe flag for a vfile row and returns the file mode. func (c *Checkout) vfilePerm(vfileID int64) (os.FileMode, error) { var isexe int64 err := c.db.QueryRow("SELECT CAST(isexe AS INTEGER) FROM vfile WHERE id=?", vfileID).Scan(&isexe) if err != nil { return 0, fmt.Errorf("query vfile permissions: %w", err) } if isexe != 0 { return 0o755, nil } return 0o644, nil } // modeIsExecutable is the inverse of vfilePerm: given an on-disk file mode, // it reports whether the file should be recorded as executable. // // Matches Fossil's file_perm() predicate (src/file.c:316): // S_ISREG(st_mode) && (S_IXUSR & st_mode) != 0 — owner-execute on a regular // file. Directories, symlinks, and other non-regular files are never // executable, and group- or other-only execute bits (e.g. 0645) do not // qualify without the owner bit. // // On Windows, os.FileMode never carries an owner-execute bit, so this always // returns false — matching Fossil's PERM_REG-only behavior on that platform. func modeIsExecutable(mode os.FileMode) bool { return mode.IsRegular() && mode&0o100 != 0 } // Rename marks a file as renamed in vfile by updating pathname and origname. // Sets chnged=1 to indicate the file has been modified. // // If opts.DoFsMove is true, also moves the file in Storage from old to new path. // // Panics if c is nil, or if From or To is empty (TigerStyle precondition). func (c *Checkout) Rename(opts RenameOpts) error { if c == nil { panic("checkout.Rename: nil *Checkout") } if opts.From == "" { panic("checkout.Rename: empty From") } if opts.To == "" { panic("checkout.Rename: empty To") } // Get current version vid, _, err := c.Version() if err != nil { return fmt.Errorf("checkout.Rename: %w", err) } vfileID, err := c.renameRowID(vid, opts.From, opts.To) if err != nil { return err } // Refuse to move onto a file already on disk, before changing anything: // it is not tracked (checked above), so it may be the only copy of // someone's work. fossil mv --hard refuses the same way. if opts.DoFsMove { if err := c.checkRenameTargetFree(opts.To); err != nil { return err } } // Update vfile: set new pathname, store old pathname in origname, mark as changed _, err = c.db.Exec( "UPDATE vfile SET pathname=?, origname=?, chnged=1 WHERE id=?", opts.To, opts.From, vfileID, ) if err != nil { return fmt.Errorf("checkout.Rename: update vfile: %w", err) } // If DoFsMove, move the file in Storage if opts.DoFsMove { perm, err := c.vfilePerm(vfileID) if err != nil { return fmt.Errorf("checkout.Rename: %w", err) } oldPath, err := c.safePath(opts.From) if err != nil { return fmt.Errorf("checkout.Rename: path traversal in %s: %w", opts.From, err) } newPath, err := c.safePath(opts.To) if err != nil { return fmt.Errorf("checkout.Rename: path traversal in %s: %w", opts.To, err) } if err := c.moveFile(oldPath, newPath, perm); err != nil { return fmt.Errorf("checkout.Rename: %w", err) } } // Call callback if provided if opts.Callback != nil { if err := opts.Callback(opts.From, opts.To); err != nil { return fmt.Errorf("checkout.Rename: callback: %w", err) } } return nil } // RevertRename restores a renamed file to its original pathname. // Returns (true, nil) if the revert succeeded, (false, nil) if there was nothing to revert. // // Panics if c is nil or name is empty (TigerStyle precondition). func (c *Checkout) RevertRename(name string, doFsMove bool) (bool, error) { if c == nil { panic("checkout.RevertRename: nil *Checkout") } if name == "" { panic("checkout.RevertRename: empty name") } // Get current version vid, _, err := c.Version() if err != nil { return false, fmt.Errorf("checkout.RevertRename: %w", err) } // Look up vfile entry var vfileID int64 var origname sql.NullString err = c.db.QueryRow( "SELECT id, origname FROM vfile WHERE vid=? AND pathname=?", int64(vid), name, ).Scan(&vfileID, &origname) if err == sql.ErrNoRows { return false, fmt.Errorf("checkout.RevertRename: file %s not found in vfile", name) } if err != nil { return false, fmt.Errorf("checkout.RevertRename: query vfile for %s: %w", name, err) } // If origname is empty/NULL, nothing to revert if !origname.Valid || origname.String == "" { return false, nil } oldOrigName := origname.String // Update vfile: restore origname as pathname, clear origname, reset chnged _, err = c.db.Exec( "UPDATE vfile SET pathname=?, origname=NULL, chnged=0 WHERE id=?", oldOrigName, vfileID, ) if err != nil { return false, fmt.Errorf("checkout.RevertRename: update vfile: %w", err) } // If doFsMove, move the file back in Storage if doFsMove { perm, err := c.vfilePerm(vfileID) if err != nil { return false, fmt.Errorf("checkout.RevertRename: %w", err) } currentPath, err := c.safePath(name) if err != nil { return false, fmt.Errorf("checkout.RevertRename: path traversal in %s: %w", name, err) } originalPath, err := c.safePath(oldOrigName) if err != nil { return false, fmt.Errorf("checkout.RevertRename: path traversal in %s: %w", oldOrigName, err) } if err := c.moveFile(currentPath, originalPath, perm); err != nil { return false, fmt.Errorf("checkout.RevertRename: %w", err) } } return true, nil } // checkRenameTargetFree returns an error if anything already exists on disk // at the checkout-relative path to. func (c *Checkout) checkRenameTargetFree(to string) error { if to == "" { panic("checkout.checkRenameTargetFree: empty path") } newPath, err := c.safePath(to) if err != nil { return fmt.Errorf("checkout.Rename: path traversal in %s: %w", to, err) } _, err = c.env.Storage.Stat(newPath) if err == nil { return fmt.Errorf( "checkout.Rename: cannot move onto %s: something already exists there", to) } if !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("checkout.Rename: stat %s: %w", newPath, err) } return nil } // renameRowID returns the vfile id of from in version vid, after checking // that to is not tracked already. func (c *Checkout) renameRowID(vid libfossil.FslID, from, to string) (int64, error) { if from == to { return 0, fmt.Errorf("checkout.Rename: %s is already named %s", from, to) } var vfileID int64 err := c.db.QueryRow( "SELECT id FROM vfile WHERE vid=? AND pathname=?", int64(vid), from, ).Scan(&vfileID) if err == sql.ErrNoRows { return 0, fmt.Errorf("checkout.Rename: file %s not found in vfile", from) } if err != nil { return 0, fmt.Errorf("checkout.Rename: query vfile for %s: %w", from, err) } var existingID int64 err = c.db.QueryRow( "SELECT id FROM vfile WHERE vid=? AND pathname=?", int64(vid), to, ).Scan(&existingID) if err == nil { return 0, fmt.Errorf("checkout.Rename: target file %s already exists in vfile", to) } if err != sql.ErrNoRows { return 0, fmt.Errorf("checkout.Rename: query vfile for %s: %w", to, err) } return vfileID, nil }