//go:build !js
// Package stash saves and restores working directory changes, storing deltas
// against baseline blobs in the checkout database (.fslckout).
package stash
import (
"bytes"
"database/sql"
"fmt"
"path/filepath"
"strconv"
"github.com/danmestas/go-libfossil/internal/checkout"
"github.com/danmestas/go-libfossil/internal/content"
"github.com/danmestas/go-libfossil/internal/delta"
libfossil "github.com/danmestas/go-libfossil/internal/fsltype"
"github.com/danmestas/go-libfossil/internal/vfile"
"github.com/danmestas/go-libfossil/simio"
)
// Entry represents a single stash entry.
type Entry struct {
ID int64
Hash string // UUID of checkout manifest (baseline version)
Comment string
CTime string
}
// EnsureTables creates the stash and stashfile tables if they don't exist.
func EnsureTables(ckout *sql.DB) error {
if ckout == nil {
panic("stash.EnsureTables: ckout must not be nil")
}
stmts := []string{
`CREATE TABLE IF NOT EXISTS stash(
stashid INTEGER PRIMARY KEY,
hash TEXT,
comment TEXT,
ctime TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS stashfile(
stashid INTEGER REFERENCES stash,
isAdded BOOLEAN,
isRemoved BOOLEAN,
isExec BOOLEAN,
isLink BOOLEAN,
hash TEXT,
origname TEXT,
newname TEXT,
delta BLOB,
PRIMARY KEY(newname, stashid)
)`,
}
for _, s := range stmts {
if _, err := ckout.Exec(s); err != nil {
return fmt.Errorf("stash.EnsureTables: %w", err)
}
}
return nil
}
// nextStashID reads and increments the stash-next vvar counter.
func nextStashID(tx *sql.Tx) (int64, error) {
var val string
err := tx.QueryRow("SELECT value FROM vvar WHERE name='stash-next'").Scan(&val)
if err != nil {
if err == sql.ErrNoRows {
// First stash: start at 1.
if _, err := tx.Exec("INSERT INTO vvar(name,value) VALUES('stash-next','2')"); err != nil {
return 0, fmt.Errorf("stash: init stash-next: %w", err)
}
return 1, nil
}
return 0, fmt.Errorf("stash: read stash-next: %w", err)
}
id, err := strconv.ParseInt(val, 10, 64)
if err != nil {
return 0, fmt.Errorf("stash: parse stash-next %q: %w", val, err)
}
if _, err := tx.Exec("REPLACE INTO vvar(name,value) VALUES('stash-next',?)", strconv.FormatInt(id+1, 10)); err != nil {
return 0, fmt.Errorf("stash: bump stash-next: %w", err)
}
return id, nil
}
// Scanner brings a checkout's vfile up to date with its working files, as
// fossil's vfile_check_signature does. A *checkout.Checkout is one.
type Scanner interface {
ScanChanges(flags checkout.ScanFlags) error
}
// Save stashes all changed files in the checkout, then reverts the working directory.
// It scans the checkout with scanner first, as fossil's stash_create does, so
// an edit no scan has recorded yet is stashed too.
func Save(
scanner Scanner, ckout *sql.DB, repoDB *sql.DB, env *simio.Env, dir string, comment string,
) error {
if scanner == nil {
panic("stash.Save: scanner must not be nil")
}
if ckout == nil {
panic("stash.Save: ckout must not be nil")
}
if repoDB == nil {
panic("stash.Save: repoDB must not be nil")
}
if env == nil {
panic("stash.Save: env must not be nil")
}
if dir == "" {
panic("stash.Save: dir must not be empty")
}
if err := scanner.ScanChanges(0); err != nil {
return fmt.Errorf("stash.Save: %w", err)
}
if err := EnsureTables(ckout); err != nil {
return err
}
tx, err := ckout.Begin()
if err != nil {
return fmt.Errorf("stash.Save: begin tx: %w", err)
}
defer tx.Rollback()
// Get checkout hash (manifest UUID).
var checkoutHash string
err = tx.QueryRow("SELECT value FROM vvar WHERE name='checkout-hash'").Scan(&checkoutHash)
if err != nil {
// Fall back to checkout rid if checkout-hash not available.
checkoutHash = ""
}
stashID, err := nextStashID(tx)
if err != nil {
return err
}
// Insert stash header, its time a Julian day as fossil's stash writes it.
if _, err := tx.Exec("INSERT INTO stash(stashid, hash, comment, ctime) VALUES(?,?,?,julianday('now'))",
stashID, checkoutHash, comment); err != nil {
return fmt.Errorf("stash.Save: insert stash: %w", err)
}
files, err := snapshotChangedFiles(tx)
if err != nil {
return err
}
if len(files) == 0 {
return fmt.Errorf("stash.Save: no changes to stash")
}
disk, err := vfile.OpenDisk(env, repoDB)
if err != nil {
return fmt.Errorf("stash.Save: %w", err)
}
if err := storeAndRevertFiles(tx, repoDB, disk, dir, stashID, files); err != nil {
return err
}
return tx.Commit()
}
// snapshotChangedFiles returns the checked-out version's changed rows,
// as vfile.Classify judges them. A pending merge, a permission change or a
// rename is refused: Save records and restores files by current name against
// their committed content with default permissions, which would drop the
// merge, the mode change or the rename.
func snapshotChangedFiles(tx *sql.Tx) ([]vfile.Row, error) {
if tx == nil {
panic("stash.snapshotChangedFiles: nil tx")
}
vid, err := checkoutVersion(tx)
if err != nil {
return nil, fmt.Errorf("stash.Save: %w", err)
}
// A merge leaves a vmerge record even for files whose rows look like
// plain edits (a clean three-way merge); stashing them would leave the
// record naming a merge whose content is no longer in the checkout.
var merging bool
if err := tx.QueryRow("SELECT EXISTS(SELECT 1 FROM vmerge)").Scan(&merging); err != nil {
return nil, fmt.Errorf("stash.Save: query vmerge: %w", err)
}
if merging {
return nil, fmt.Errorf("stash.Save: a merge is pending; commit or revert it first")
}
rows, err := vfile.Load(tx, vid)
if err != nil {
return nil, fmt.Errorf("stash.Save: %w", err)
}
var files []vfile.Row
for _, r := range rows {
if vfile.Classify(r, false) == vfile.ChangeNone {
continue
}
if r.PendingMerge() {
return nil, fmt.Errorf(
"stash.Save: %s has a pending merge; commit or revert it first", r.Pathname)
}
if r.ModeChanged() {
return nil, fmt.Errorf(
"stash.Save: %s has a pending permission change; commit or revert it first",
r.Pathname)
}
if r.RenamedFrom() != "" {
return nil, fmt.Errorf(
"stash.Save: %s is a pending rename; commit or revert it first", r.Pathname)
}
files = append(files, r)
}
return files, nil
}
// storeAndRevertFiles records each changed file in the stash, then reverts
// it to its committed version. Files are read and written through disk, as
// fossil's stash and revert see them with RepoFILE.
func storeAndRevertFiles(
tx *sql.Tx, repoDB *sql.DB, disk vfile.Disk, dir string, stashID int64, files []vfile.Row,
) error {
if stashID <= 0 {
panic("stash.storeAndRevertFiles: stashID must be positive")
}
ins, err := tx.Prepare(`INSERT INTO stashfile(stashid, isAdded, isRemoved, isExec, isLink,
hash, origname, newname, delta) VALUES(?,?,?,?,?,?,?,?,?)`)
if err != nil {
return fmt.Errorf("stash.Save: prepare insert: %w", err)
}
defer ins.Close()
for _, f := range files {
fullPath := filepath.Join(dir, f.Pathname)
rec, err := stashRecord(repoDB, disk, fullPath, f)
if err != nil {
return err
}
if _, err := ins.Exec(stashID, f.IsAdded(), f.IsRemoved(), f.IsExe != 0, rec.isLink,
nullStr(rec.baselineHash), f.Pathname, f.Pathname, rec.delta); err != nil {
return fmt.Errorf("stash.Save: insert stashfile %s: %w", f.Pathname, err)
}
if err := revertStashedFile(tx, repoDB, disk, fullPath, f); err != nil {
return err
}
}
return nil
}
// stashed is what the stash keeps for a changed file.
type stashed struct {
baselineHash string // the committed artifact; empty for an added file
delta []byte // an added file's content, or a delta to the working content
isLink bool // the working file is a symlink seen as itself
}
// stashRecord returns what the stash keeps for f, read through disk as
// fossil's stash_add_file_or_dir reads it: an added file's content with no
// baseline, a removed file's baseline hash with an empty delta, or a
// modified file's baseline hash with a delta from baseline to its content.
// A symlink's content is its target's name, and it is recorded as a link
// whatever vfile says, as fossil records file_islink.
func stashRecord(
repoDB *sql.DB, disk vfile.Disk, fullPath string, f vfile.Row,
) (stashed, error) {
info, err := disk.Stat(fullPath)
if err != nil {
return stashed{}, fmt.Errorf("stash.Save: %w", err)
}
rec := stashed{isLink: vfile.IsLink(info)}
if f.IsAdded() {
if rec.delta, err = disk.Read(fullPath, info); err != nil {
return stashed{}, fmt.Errorf("stash.Save: read added %s: %w", f.Pathname, err)
}
return rec, nil
}
if err := repoDB.QueryRow(
"SELECT uuid FROM blob WHERE rid=?", f.RID,
).Scan(&rec.baselineHash); err != nil {
return stashed{}, fmt.Errorf("stash.Save: get uuid for rid=%d: %w", f.RID, err)
}
if f.IsRemoved() {
rec.delta = []byte{}
return rec, nil
}
baseline, err := content.Expand(repoDB, libfossil.FslID(f.RID))
if err != nil {
return stashed{}, fmt.Errorf("stash.Save: expand rid=%d: %w", f.RID, err)
}
working, err := disk.Read(fullPath, info)
if err != nil {
return stashed{}, fmt.Errorf("stash.Save: read %s: %w", f.Pathname, err)
}
if rec.delta, err = delta.Create(baseline, working); err != nil {
return stashed{}, fmt.Errorf("stash.Save: delta %s: %w", f.Pathname, err)
}
return rec, nil
}
// revertStashedFile undoes f's change as fossil's revert does: an added
// file is removed from disk and vfile; a removed or modified file gets its
// committed content back, with the row's executable and symlink bits
// (vfile.Disk.Write), and its row is marked unchanged, with the mtime it
// was written with.
func revertStashedFile(
tx *sql.Tx, repoDB *sql.DB, disk vfile.Disk, fullPath string, f vfile.Row,
) error {
if f.IsAdded() {
if err := disk.Remove(fullPath); err != nil {
return fmt.Errorf("stash.Save: remove added %s: %w", f.Pathname, err)
}
if _, err := tx.Exec("DELETE FROM vfile WHERE id=?", f.ID); err != nil {
return fmt.Errorf("stash.Save: delete vfile %s: %w", f.Pathname, err)
}
return nil
}
baseline, err := content.Expand(repoDB, libfossil.FslID(f.RID))
if err != nil {
return fmt.Errorf("stash.Save: expand rid=%d for revert: %w", f.RID, err)
}
mtime, err := writeFile(disk, fullPath, baseline, f.IsExe != 0, f.IsLink != 0)
if err != nil {
return fmt.Errorf("stash.Save: %w", err)
}
if _, err := tx.Exec(
"UPDATE vfile SET deleted=0, chnged=0, mtime=? WHERE id=?", mtime, f.ID,
); err != nil {
return fmt.Errorf("stash.Save: update vfile %s: %w", f.Pathname, err)
}
return nil
}
// writeFile writes data to fullPath through disk, a symlink to data when
// link is set, and returns the vfile.mtime to record for it.
func writeFile(disk vfile.Disk, fullPath string, data []byte, isExec, link bool) (int64, error) {
if err := disk.Write(fullPath, data, isExec, link); err != nil {
return 0, err
}
return disk.MTime(fullPath)
}
// stashedFile is one stashfile row, as Apply reads it.
type stashedFile struct {
isAdded bool
isRemoved bool
isExec bool
isLink bool
hash sql.NullString
name string
delta []byte
}
// Apply restores stashed files to the working directory without removing the stash entry.
//
// Files are written through env's storage as fossil sees them with RepoFILE
// (vfile.Disk): a stashed symlink is made a symlink again, and nothing is
// written through one. As fossil's stash_apply does, an
// added file is added again, and a removed file is deleted from disk with its
// row left as it is, so it shows as missing. A modified file's row records
// what a scan would find: whether the written content differs from the row's
// artifact, and the mtime it was written with (vfile.WrittenMTime). A row
// based on another artifact, or in a merge, gets mtime 0 instead, so the next
// scan hashes the file.
//
// A modified file that cannot be merged because a symlink is involved is
// left as it is and returned in conflicts, as fossil's stash_apply reports
// "Cannot merge symlink" and counts a conflict (see applyStashedFile).
func Apply(
ckout *sql.DB, repoDB *sql.DB, env *simio.Env, dir string, stashID int64,
) (conflicts []string, err error) {
if ckout == nil {
panic("stash.Apply: ckout must not be nil")
}
if repoDB == nil {
panic("stash.Apply: repoDB must not be nil")
}
if env == nil {
panic("stash.Apply: env must not be nil")
}
if dir == "" {
panic("stash.Apply: dir must not be empty")
}
if stashID <= 0 {
panic("stash.Apply: stashID must be positive")
}
files, err := loadStashedFiles(ckout, stashID)
if err != nil {
return nil, err
}
if len(files) == 0 {
return nil, fmt.Errorf("stash.Apply: stash %d not found", stashID)
}
paths := make([]string, len(files))
for i, f := range files {
paths[i] = f.name
}
if err := checkout.CheckSparsePaths(ckout, "StashApply", paths); err != nil {
return nil, fmt.Errorf("stash.Apply: %w", err)
}
tx, err := ckout.Begin()
if err != nil {
return nil, fmt.Errorf("stash.Apply: begin tx: %w", err)
}
defer tx.Rollback()
vid, err := checkoutVersion(tx)
if err != nil {
return nil, fmt.Errorf("stash.Apply: %w", err)
}
disk, err := vfile.OpenDisk(env, repoDB)
if err != nil {
return nil, fmt.Errorf("stash.Apply: %w", err)
}
for _, f := range files {
merged, err := applyStashedFile(tx, repoDB, disk, vid, filepath.Join(dir, f.name), f)
if err != nil {
return nil, fmt.Errorf("stash.Apply: %w", err)
}
if !merged {
conflicts = append(conflicts, f.name)
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("stash.Apply: commit: %w", err)
}
return conflicts, nil
}
// loadStashedFiles reads stash stashID's files up front, so no cursor is
// held open while they are written and their rows updated.
func loadStashedFiles(ckout *sql.DB, stashID int64) ([]stashedFile, error) {
rows, err := ckout.Query(`SELECT isAdded, isRemoved, isExec, isLink, hash, newname, delta
FROM stashfile WHERE stashid=?`, stashID)
if err != nil {
return nil, fmt.Errorf("stash.Apply: query stashfile: %w", err)
}
defer rows.Close()
var files []stashedFile
for rows.Next() {
var f stashedFile
var isExec, isLink sql.NullBool
if err := rows.Scan(
&f.isAdded, &f.isRemoved, &isExec, &isLink, &f.hash, &f.name, &f.delta,
); err != nil {
return nil, fmt.Errorf("stash.Apply: scan stashfile: %w", err)
}
f.isExec = isExec.Bool
f.isLink = isLink.Bool
files = append(files, f)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("stash.Apply: rows iteration: %w", err)
}
return files, nil
}
// applyStashedFile restores one stashed file in version vid's checkout,
// writing through disk, and reports whether it did. As fossil's stash_apply
// does, an added file is written as a plain file, and a modified symlink as
// a symlink. A modified file where a symlink was stashed or is now, unless
// both are links and the link is still the stash's baseline, is not merged:
// fossil prints "Cannot merge symlink", counts a conflict and leaves the
// file and its row as they are, which is reported as not merged.
func applyStashedFile(
tx *sql.Tx, repoDB *sql.DB, disk vfile.Disk, vid int64, fullPath string, f stashedFile,
) (merged bool, err error) {
switch {
case f.isAdded:
if _, err := writeFile(disk, fullPath, f.delta, f.isExec, false); err != nil {
return false, err
}
return true, readd(tx, vid, f)
case f.isRemoved:
return true, disk.Remove(fullPath)
}
if !f.hash.Valid {
return false, fmt.Errorf("missing baseline hash for %s", f.name)
}
rid, ok := content.AvailableByUUID(repoDB, f.hash.String)
if !ok {
return false, fmt.Errorf("baseline blob %s not found", f.hash.String)
}
baseline, err := content.Expand(repoDB, rid)
if err != nil {
return false, fmt.Errorf("expand baseline %s: %w", f.hash.String, err)
}
result, err := delta.Apply(baseline, f.delta)
if err != nil {
return false, fmt.Errorf("apply delta for %s: %w", f.name, err)
}
if f.isLink || disk.Symlinks() {
mergeable, err := symlinkUnchanged(disk, fullPath, f.isLink, baseline)
if err != nil || !mergeable {
return false, err
}
}
mtime, err := writeFile(disk, fullPath, result, f.isExec, f.isLink)
if err != nil {
return false, err
}
if _, err := tx.Exec(`
UPDATE vfile
SET chnged = CASE WHEN plain THEN ? ELSE chnged END,
mtime = CASE WHEN plain THEN ? ELSE 0 END
FROM (SELECT chnged IN (0, 1) AND NOT deleted AND mrid = rid AND rid = ? AS plain
FROM vfile WHERE vid = ? AND pathname = ?)
WHERE vid = ? AND pathname = ?`,
!bytes.Equal(result, baseline), mtime, int64(rid), vid, f.name, vid, f.name,
); err != nil {
return false, fmt.Errorf("update vfile %s: %w", f.name, err)
}
return true, nil
}
// symlinkUnchanged is fossil's stash_apply test for writing a stashed file
// where a symlink is involved: the file at fullPath must be a symlink just
// when the stashed one was (isLink), and hold the baseline. Otherwise fossil
// will not merge it. Where neither is a symlink, fossil merges and this
// reports true. A missing file reads as empty, as fossil reads it.
func symlinkUnchanged(disk vfile.Disk, fullPath string, isLink bool, baseline []byte) (bool, error) {
info, err := disk.Stat(fullPath)
if err != nil {
return false, err
}
isNewLink := vfile.IsLink(info)
if !isLink && !isNewLink {
return true, nil
}
if isLink != isNewLink {
return false, nil
}
current, err := disk.Read(fullPath, info)
if err != nil {
return false, err
}
return bytes.Equal(current, baseline), nil
}
// readd adds a stashed added file to version vid again, as fossil's
// stash_apply does through add_one_file: a removed row is restored, and a
// missing one is inserted as Manage inserts an added file.
func readd(tx *sql.Tx, vid int64, f stashedFile) error {
var exists bool
if err := tx.QueryRow(
"SELECT EXISTS(SELECT 1 FROM vfile WHERE vid=? AND pathname=?)", vid, f.name,
).Scan(&exists); err != nil {
return fmt.Errorf("query vfile %s: %w", f.name, err)
}
if exists {
if _, err := tx.Exec(
"UPDATE vfile SET deleted=0 WHERE vid=? AND pathname=? AND deleted", vid, f.name,
); err != nil {
return fmt.Errorf("restore vfile %s: %w", f.name, err)
}
return nil
}
if _, err := tx.Exec(
"INSERT INTO vfile(vid, pathname, rid, mrid, isexe, islink, chnged) VALUES(?, ?, 0, 0, ?, 0, 1)",
vid, f.name, f.isExec,
); err != nil {
return fmt.Errorf("add vfile %s: %w", f.name, err)
}
return nil
}
// checkoutVersion returns the checked-out version, vvar's checkout.
func checkoutVersion(tx *sql.Tx) (int64, error) {
var text string
if err := tx.QueryRow("SELECT value FROM vvar WHERE name='checkout'").Scan(&text); err != nil {
return 0, fmt.Errorf("read checkout version: %w", err)
}
vid, err := strconv.ParseInt(text, 10, 64)
if err != nil {
return 0, fmt.Errorf("checkout version %q: %w", text, err)
}
return vid, nil
}
// Pop applies the most recent stash entry and removes it, returning the
// files Apply did not merge. As fossil's stash pop does, the entry is
// removed even then.
func Pop(ckout *sql.DB, repoDB *sql.DB, env *simio.Env, dir string) (conflicts []string, err error) {
if ckout == nil {
panic("stash.Pop: ckout must not be nil")
}
if repoDB == nil {
panic("stash.Pop: repoDB must not be nil")
}
if env == nil {
panic("stash.Pop: env must not be nil")
}
if dir == "" {
panic("stash.Pop: dir must not be empty")
}
var stashID int64
err = ckout.QueryRow("SELECT stashid FROM stash ORDER BY stashid DESC LIMIT 1").Scan(&stashID)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("stash.Pop: no stash entries")
}
return nil, fmt.Errorf("stash.Pop: query top stash: %w", err)
}
if conflicts, err = Apply(ckout, repoDB, env, dir, stashID); err != nil {
return nil, err
}
return conflicts, Drop(ckout, stashID)
}
// List returns all stash entries oldest first, ordered by creation time as
// fossil's stash ls orders them, with the stash id breaking a tie.
func List(ckout *sql.DB) ([]Entry, error) {
if ckout == nil {
panic("stash.List: ckout must not be nil")
}
if err := EnsureTables(ckout); err != nil {
return nil, err
}
// datetime() shows the time as fossil's stash ls does, whether the row
// holds a Julian day or text.
rows, err := ckout.Query("SELECT stashid, hash, comment, datetime(ctime) FROM stash ORDER BY ctime, stashid")
if err != nil {
return nil, fmt.Errorf("stash.List: %w", err)
}
defer rows.Close()
var entries []Entry
for rows.Next() {
var e Entry
var h, c sql.NullString
var ct sql.NullString
if err := rows.Scan(&e.ID, &h, &c, &ct); err != nil {
return nil, fmt.Errorf("stash.List: scan: %w", err)
}
e.Hash = h.String
e.Comment = c.String
e.CTime = ct.String
entries = append(entries, e)
}
return entries, rows.Err()
}
// Drop removes a specific stash entry and its files.
func Drop(ckout *sql.DB, stashID int64) error {
if ckout == nil {
panic("stash.Drop: ckout must not be nil")
}
if stashID <= 0 {
panic("stash.Drop: stashID must be positive")
}
tx, err := ckout.Begin()
if err != nil {
return fmt.Errorf("stash.Drop: begin tx: %w", err)
}
defer tx.Rollback()
if _, err := tx.Exec("DELETE FROM stashfile WHERE stashid=?", stashID); err != nil {
return fmt.Errorf("stash.Drop: delete stashfile: %w", err)
}
res, err := tx.Exec("DELETE FROM stash WHERE stashid=?", stashID)
if err != nil {
return fmt.Errorf("stash.Drop: delete stash: %w", err)
}
n, raErr := res.RowsAffected()
if raErr != nil {
return fmt.Errorf("stash.Drop: rows affected: %w", raErr)
}
if n == 0 {
return fmt.Errorf("stash.Drop: stash %d not found", stashID)
}
return tx.Commit()
}
// Clear removes all stash entries.
func Clear(ckout *sql.DB) error {
if ckout == nil {
panic("stash.Clear: ckout must not be nil")
}
tx, err := ckout.Begin()
if err != nil {
return fmt.Errorf("stash.Clear: begin tx: %w", err)
}
defer tx.Rollback()
if _, err := tx.Exec("DELETE FROM stashfile"); err != nil {
return fmt.Errorf("stash.Clear: delete stashfile: %w", err)
}
if _, err := tx.Exec("DELETE FROM stash"); err != nil {
return fmt.Errorf("stash.Clear: delete stash: %w", err)
}
return tx.Commit()
}
// nullStr returns a sql.NullString: valid if s is non-empty.
func nullStr(s string) sql.NullString {
if s == "" {
return sql.NullString{}
}
return sql.NullString{String: s, Valid: true}
}