Artifact 8487433752557d52f9172ffb939e3104b4d0c7b416c7dae78aa54429828486e8:
- File
cli/shared.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: 5444) [annotate] [blame] [check-ins using]
package cli import ( "database/sql" "fmt" "os" "os/user" "path/filepath" libdb "github.com/danmestas/go-libfossil/db" libfossil "github.com/danmestas/go-libfossil" ) // Globals holds flags shared by all CLI commands. type Globals struct { Repo string `short:"R" help:"Path to repository file" type:"path"` Verbose bool `short:"v" help:"Verbose output"` } // OpenRepo opens a Fossil repository using the handle API. // If Repo is empty, it searches for a .fossil file or .fslckout checkout. func (g *Globals) OpenRepo() (*libfossil.Repo, error) { if g.Repo == "" { found, err := findRepo() if err != nil { return nil, fmt.Errorf("no repository specified (use -R <path>)") } g.Repo = found } return libfossil.Open(g.Repo) } // resolveRID resolves a version string to a rid. // Accepts: empty/"tip" (most recent checkin), "trunk" (tagged trunk tip), // named branch, UUID prefix (min 4 chars), or full UUID. // // This is a thin CLI-layer wrapper around Repo.ResolveVersion, which holds // the canonical resolution logic. func resolveRID(r *libfossil.Repo, version string) (int64, error) { return r.ResolveVersion(version) } // currentUser returns the current OS username, or "anonymous" if unavailable. func currentUser() string { if u, err := user.Current(); err == nil { return u.Username } return "anonymous" } // findRepo searches the current directory and its parents for a .fossil file // or a .fslckout checkout database that points to a repo. func findRepo() (string, error) { dir, err := os.Getwd() if err != nil { return "", err } for { ckout := filepath.Join(dir, ".fslckout") if _, err := os.Stat(ckout); err == nil { return repoFromCheckout(ckout) } matches, _ := filepath.Glob(filepath.Join(dir, "*.fossil")) if len(matches) == 1 { return matches[0], nil } parent := filepath.Dir(dir) if parent == dir { break } dir = parent } return "", fmt.Errorf("no .fossil file found") } // repoFromCheckout reads the repository path from a checkout database. A // relative path, as fossil open stores it, is relative to the checkout's // directory. The database is opened read-write: forcing read-only clashes // with the WAL pragma every connection sets, on a database fossil left in // delete journal mode; the library opens it read-write as well. func repoFromCheckout(ckoutPath string) (path string, err error) { db, err := libdb.OpenSQL(ckoutPath, libdb.OpenConfig{}, nil) if err != nil { return "", fmt.Errorf("checkout %s: %w", ckoutPath, err) } defer func() { if cerr := db.Close(); cerr != nil && err == nil { err = cerr } }() var repoPath string if err := db.QueryRow( "SELECT value FROM vvar WHERE name='repository'", ).Scan(&repoPath); err != nil { return "", fmt.Errorf("checkout %s: read repository path: %w", ckoutPath, err) } if !filepath.IsAbs(repoPath) { repoPath = filepath.Join(filepath.Dir(ckoutPath), repoPath) } return repoPath, nil } // openWorkingCheckout opens the checkout in dir through the library, with // the repository it belongs to: -R when given, otherwise the repository the // checkout records. done closes both and reports the first error. func openWorkingCheckout( g *Globals, dir string, ) (co *libfossil.Checkout, done func() error, err error) { if g == nil { panic("cli.openWorkingCheckout: nil globals") } if dir == "" { panic("cli.openWorkingCheckout: empty dir") } if g.Repo == "" { ckoutPath, err := checkoutDBPath(dir) if err != nil { return nil, nil, err } if g.Repo, err = repoFromCheckout(ckoutPath); err != nil { return nil, nil, err } } r, err := g.OpenRepo() if err != nil { return nil, nil, err } co, err = r.OpenCheckout(dir, libfossil.CheckoutOpenOpts{}) if err != nil { if cerr := r.Close(); cerr != nil { return nil, nil, fmt.Errorf("%w (closing repository: %v)", err, cerr) } return nil, nil, err } done = func() error { coErr := co.Close() repoErr := r.Close() if coErr != nil { return coErr } return repoErr } return co, done, nil } // closeWith runs done and keeps its error when the command had none, so a // failure to close is reported rather than lost. func closeWith(done func() error, err *error) { if cerr := done(); cerr != nil && *err == nil { *err = cerr } } // checkoutDBPath returns the path of the checkout database in dir. func checkoutDBPath(dir string) (string, error) { for _, name := range []string{".fslckout", "_FOSSIL_"} { path := filepath.Join(dir, name) if _, err := os.Stat(path); err == nil { return path, nil } } return "", fmt.Errorf("no checkout found in %s (run 'fossil repo open' first)", dir) } // openCheckout opens the .fslckout database in the given directory. func openCheckout(dir string) (*sql.DB, error) { ckoutPath := filepath.Join(dir, ".fslckout") if _, err := os.Stat(ckoutPath); err != nil { ckoutPath = filepath.Join(dir, "_FOSSIL_") if _, err := os.Stat(ckoutPath); err != nil { return nil, fmt.Errorf("no checkout found in %s (run 'fossil repo open' first)", dir) } } return libdb.OpenSQL(ckoutPath, libdb.OpenConfig{}, nil) } // checkoutVid returns the current checkout version ID from vvar. func checkoutVid(db *sql.DB) (int64, error) { var vid int64 err := db.QueryRow("SELECT value FROM vvar WHERE name='checkout'").Scan(&vid) if err != nil { return 0, fmt.Errorf("reading checkout version: %w", err) } return vid, nil }