//go:build !js
package stash_test
import (
"database/sql"
"errors"
"os"
"path/filepath"
"testing"
"time"
libdb "github.com/danmestas/go-libfossil/db"
"github.com/danmestas/go-libfossil/internal/checkout"
"github.com/danmestas/go-libfossil/internal/cone"
"github.com/danmestas/go-libfossil/internal/manifest"
"github.com/danmestas/go-libfossil/internal/repo"
"github.com/danmestas/go-libfossil/internal/stash"
_ "github.com/danmestas/go-libfossil/internal/testdriver"
"github.com/danmestas/go-libfossil/simio"
)
func TestStashOutsideConeRequiresWidening(t *testing.T) {
s := newSimCheckout(t, later)
s.write(t, "a.txt", "saved outside edit\n")
if err := stash.Save(s.co, s.ckout, s.repoDB, s.env, s.dir, "before narrowing"); err != nil {
t.Fatal(err)
}
if err := s.ckout.Close(); err != nil {
t.Fatal(err)
}
sparse, err := cone.New([]string{"src"}, nil)
if err != nil {
t.Fatal(err)
}
if _, err := s.co.SetCone(sparse); err != nil {
t.Fatal(err)
}
openMetadata := func() *sql.DB {
t.Helper()
name, err := checkout.FindCheckoutDB(simio.OSStorage{}, s.dir, false)
if err != nil {
t.Fatal(err)
}
metadata, err := libdb.OpenSQL(name, libdb.OpenConfig{}, nil)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { metadata.Close() })
return metadata
}
metadata := openMetadata()
if _, err := stash.Apply(metadata, s.repoDB, s.env, s.dir, 1); !errors.Is(err, checkout.ErrOutsideCone) {
t.Fatalf("outside stash apply = %v", err)
}
if _, err := s.env.Storage.Stat(filepath.Join(s.dir, "a.txt")); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("refused stash materialized outside file: %v", err)
}
if err := metadata.Close(); err != nil {
t.Fatal(err)
}
if _, err := s.co.SetCone(nil); err != nil {
t.Fatal(err)
}
metadata = openMetadata()
if _, err := stash.Apply(metadata, s.repoDB, s.env, s.dir, 1); err != nil {
t.Fatal(err)
}
if got, exists := s.read(t, "a.txt"); !exists || got != "saved outside edit\n" {
t.Fatalf("widening lost stash bytes: %q, exists=%v", got, exists)
}
}
// simCheckout is a checkout whose files live in a MemStorage and whose
// clock is simulated; only the databases are on disk.
type simCheckout struct {
co *checkout.Checkout
ckout *sql.DB
repoDB *sql.DB
env *simio.Env
dir string
}
// newSimCheckout commits a.txt and b.txt, extracts them into a MemStorage
// checkout with the clock at now, and scans once so vfile records each
// file's mtime.
func newSimCheckout(t *testing.T, now time.Time) *simCheckout {
t.Helper()
r, err := repo.CreateWithEnv(filepath.Join(t.TempDir(), "r.fossil"), "test", simio.RealEnv(), "")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { r.Close() })
rid, _, err := manifest.Checkin(r, manifest.CheckinOpts{
Files: []manifest.File{
{Name: "a.txt", Content: []byte("hello world\n")},
{Name: "b.txt", Content: []byte("bee\n")},
},
Comment: "initial",
User: "test",
Time: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
})
if err != nil {
t.Fatal(err)
}
env := &simio.Env{
Clock: simio.NewSimClockAt(now),
Rand: simio.NewSeededRand(1),
Storage: simio.NewMemStorage(),
}
dir := t.TempDir()
co, err := checkout.Create(r, dir, checkout.CreateOpts{Env: env})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { co.Close() })
if err := co.Extract(rid, checkout.ExtractOpts{Force: true}); err != nil {
t.Fatal(err)
}
if err := co.ScanChanges(0); err != nil {
t.Fatal(err)
}
ckout, err := libdb.OpenSQL(filepath.Join(dir, checkout.PreferredDBName()), libdb.OpenConfig{}, nil)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ckout.Close() })
return &simCheckout{co: co, ckout: ckout, repoDB: r.DB().SqlDB(), env: env, dir: dir}
}
// later is a clock well after every MemStorage write stamp, so each write's
// mtime is recordable.
var later = time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
func (s *simCheckout) write(t *testing.T, name, body string) {
t.Helper()
if err := s.env.Storage.WriteFile(filepath.Join(s.dir, name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
func (s *simCheckout) read(t *testing.T, name string) (string, bool) {
t.Helper()
data, err := s.env.Storage.ReadFile(filepath.Join(s.dir, name))
if err != nil {
return "", false
}
return string(data), true
}
func (s *simCheckout) diskMTime(t *testing.T, name string) int64 {
t.Helper()
info, err := s.env.Storage.Stat(filepath.Join(s.dir, name))
if err != nil {
t.Fatal(err)
}
return info.ModTime().Unix()
}
// row returns name's vfile chnged and mtime; ok is false when it has no row.
func (s *simCheckout) row(t *testing.T, name string) (chnged, mtime int64, ok bool) {
t.Helper()
err := s.ckout.QueryRow(
"SELECT chnged, CAST(coalesce(mtime,0) AS INTEGER) FROM vfile WHERE pathname=?", name,
).Scan(&chnged, &mtime)
if err == sql.ErrNoRows {
return 0, 0, false
}
if err != nil {
t.Fatal(err)
}
return chnged, mtime, true
}
// changes runs a status scan and returns what it reports, by name.
func (s *simCheckout) changes(t *testing.T) map[string]checkout.FileChange {
t.Helper()
vid, _, err := s.co.Version()
if err != nil {
t.Fatal(err)
}
out := map[string]checkout.FileChange{}
if err := s.co.VisitChanges(vid, true, func(e checkout.ChangeEntry) error {
out[e.Name] = e.Change
return nil
}); err != nil {
t.Fatal(err)
}
return out
}
func (s *simCheckout) setMTime(t *testing.T, name string, secs int64) {
t.Helper()
when := time.Unix(secs, 0)
if err := s.env.Storage.Chtimes(filepath.Join(s.dir, name), when, when); err != nil {
t.Fatal(err)
}
}
// TestSaveRevertsThroughStorage: stash save reads the edit from, and writes
// the baseline back to, the checkout's storage, and records the written
// file's mtime on its now unchanged row, as fossil's revert does.
func TestSaveRevertsThroughStorage(t *testing.T) {
s := newSimCheckout(t, later)
s.write(t, "a.txt", "HELLO world\n")
if got := s.changes(t); got["a.txt"] != checkout.ChangeModified {
t.Fatalf("before save: changes = %v", got)
}
if err := stash.Save(s.co, s.ckout, s.repoDB, s.env, s.dir, "edit"); err != nil {
t.Fatal(err)
}
if got, _ := s.read(t, "a.txt"); got != "hello world\n" {
t.Fatalf("a.txt after save = %q", got)
}
chnged, mtime, _ := s.row(t, "a.txt")
if chnged != 0 || mtime != s.diskMTime(t, "a.txt") || mtime == 0 {
t.Fatalf("a.txt row after save: chnged=%d mtime=%d, disk mtime %d",
chnged, mtime, s.diskMTime(t, "a.txt"))
}
if got := s.changes(t); len(got) != 0 {
t.Fatalf("after save: changes = %v", got)
}
}
// TestPopRestoresThroughStorage: pop writes the stashed edit back through
// the checkout's storage and leaves the row saying what the scan would:
// changed, with the written file's mtime.
func TestPopRestoresThroughStorage(t *testing.T) {
s := newSimCheckout(t, later)
s.write(t, "a.txt", "HELLO world\n")
if err := stash.Save(s.co, s.ckout, s.repoDB, s.env, s.dir, "edit"); err != nil {
t.Fatal(err)
}
if _, err := stash.Pop(s.ckout, s.repoDB, s.env, s.dir); err != nil {
t.Fatal(err)
}
if got, _ := s.read(t, "a.txt"); got != "HELLO world\n" {
t.Fatalf("a.txt after pop = %q", got)
}
chnged, mtime, _ := s.row(t, "a.txt")
if chnged != 1 || mtime != s.diskMTime(t, "a.txt") {
t.Fatalf("a.txt row after pop: chnged=%d mtime=%d, disk mtime %d",
chnged, mtime, s.diskMTime(t, "a.txt"))
}
if got := s.changes(t); len(got) != 1 || got["a.txt"] != checkout.ChangeModified {
t.Fatalf("after pop: changes = %v", got)
}
}
// TestSameSizePopInSaveSecondDetected: a pop whose write lands on the very
// mtime the save recorded, with an edit of the same size, is still reported.
// Fossil's apply leaves the reverted row (chnged=0, that mtime) in place, so
// its next scan trusts it and misses the edit.
func TestSameSizePopInSaveSecondDetected(t *testing.T) {
s := newSimCheckout(t, later)
s.write(t, "a.txt", "HELLO world\n")
if err := stash.Save(s.co, s.ckout, s.repoDB, s.env, s.dir, "edit"); err != nil {
t.Fatal(err)
}
_, saved, _ := s.row(t, "a.txt")
if _, err := stash.Pop(s.ckout, s.repoDB, s.env, s.dir); err != nil {
t.Fatal(err)
}
s.setMTime(t, "a.txt", saved)
if got := s.changes(t); got["a.txt"] != checkout.ChangeModified {
t.Fatalf("same-size pop in the save's second: changes = %v", got)
}
}
// TestSaveInWriteSecondRecordsNothing: a revert written in the clock's
// current second is recorded as 0, so a same-size edit in that second, which
// keeps the mtime, is still found.
func TestSaveInWriteSecondRecordsNothing(t *testing.T) {
s := newSimCheckout(t, time.Unix(0, 0))
s.write(t, "a.txt", "HELLO world\n")
if err := stash.Save(s.co, s.ckout, s.repoDB, s.env, s.dir, "edit"); err != nil {
t.Fatal(err)
}
if _, mtime, _ := s.row(t, "a.txt"); mtime != 0 {
t.Fatalf("a.txt mtime recorded in the write's second = %d, want 0", mtime)
}
reverted := s.diskMTime(t, "a.txt")
s.write(t, "a.txt", "hello WORLD\n")
s.setMTime(t, "a.txt", reverted)
if got := s.changes(t); got["a.txt"] != checkout.ChangeModified {
t.Fatalf("same-second same-size edit: changes = %v", got)
}
}
// TestApplyReaddsAddedFile: apply writes an added file back through storage
// and adds it again, as fossil's stash_apply does (stash_add_files_in_sfile);
// the stash keeps the entry.
func TestApplyReaddsAddedFile(t *testing.T) {
s := newSimCheckout(t, later)
s.write(t, "c.txt", "new file\n")
if _, err := s.co.Manage(checkout.ManageOpts{Paths: []string{"c.txt"}}); err != nil {
t.Fatal(err)
}
if err := stash.Save(s.co, s.ckout, s.repoDB, s.env, s.dir, "add"); err != nil {
t.Fatal(err)
}
if _, ok := s.read(t, "c.txt"); ok {
t.Fatal("c.txt still in storage after save")
}
if _, _, ok := s.row(t, "c.txt"); ok {
t.Fatal("c.txt still in vfile after save")
}
entries, err := stash.List(s.ckout)
if err != nil || len(entries) != 1 {
t.Fatalf("stash list = %v, %v", entries, err)
}
if _, err := stash.Apply(s.ckout, s.repoDB, s.env, s.dir, entries[0].ID); err != nil {
t.Fatal(err)
}
if got, _ := s.read(t, "c.txt"); got != "new file\n" {
t.Fatalf("c.txt after apply = %q", got)
}
if got := s.changes(t); len(got) != 1 || got["c.txt"] != checkout.ChangeAdded {
t.Fatalf("after apply: changes = %v", got)
}
if entries, err := stash.List(s.ckout); err != nil || len(entries) != 1 {
t.Fatalf("stash list after apply = %v, %v", entries, err)
}
}
// TestSaveRestoresRemovedFile: a removed file comes back through storage on
// save and is gone from storage again after pop, its row left as fossil's
// apply leaves it (the file is missing, not removed).
func TestSaveRestoresRemovedFile(t *testing.T) {
s := newSimCheckout(t, later)
if err := s.env.Storage.Remove(filepath.Join(s.dir, "b.txt")); err != nil {
t.Fatal(err)
}
if _, err := s.ckout.Exec("UPDATE vfile SET deleted=1 WHERE pathname='b.txt'"); err != nil {
t.Fatal(err)
}
if err := stash.Save(s.co, s.ckout, s.repoDB, s.env, s.dir, "rm"); err != nil {
t.Fatal(err)
}
if got, _ := s.read(t, "b.txt"); got != "bee\n" {
t.Fatalf("b.txt after save = %q", got)
}
if chnged, mtime, _ := s.row(t, "b.txt"); chnged != 0 || mtime != s.diskMTime(t, "b.txt") {
t.Fatalf("b.txt row after save: chnged=%d mtime=%d", chnged, mtime)
}
if _, err := stash.Pop(s.ckout, s.repoDB, s.env, s.dir); err != nil {
t.Fatal(err)
}
if _, ok := s.read(t, "b.txt"); ok {
t.Fatal("b.txt still in storage after pop")
}
if got := s.changes(t); got["b.txt"] != checkout.ChangeMissing {
t.Fatalf("after pop: changes = %v", got)
}
}
// TestSaveScansFirst: an edit no scan has recorded yet is still stashed,
// since stash save scans first, as fossil's stash_create does
// (vfile_check_signature).
func TestSaveScansFirst(t *testing.T) {
s := newSimCheckout(t, later)
s.write(t, "a.txt", "HELLO world\n")
if chnged, _, _ := s.row(t, "a.txt"); chnged != 0 {
t.Fatalf("premise: a.txt row already changed before any scan")
}
if err := stash.Save(s.co, s.ckout, s.repoDB, s.env, s.dir, "unscanned"); err != nil {
t.Fatal(err)
}
if got, _ := s.read(t, "a.txt"); got != "hello world\n" {
t.Fatalf("a.txt after save = %q", got)
}
if _, err := stash.Pop(s.ckout, s.repoDB, s.env, s.dir); err != nil {
t.Fatal(err)
}
if got, _ := s.read(t, "a.txt"); got != "HELLO world\n" {
t.Fatalf("a.txt after pop = %q", got)
}
}