Artifact 6c6839f92627456bcd12fc438d7bee6dd2820ba6f2280d53625372e5cb238fe4:
- File
internal/stash/stash_test.go
— part of check-in
[04e3ea8301]
at
2026-10-21 12:41:01
on branch feat/submanifest-reader
— fix(stash): merge popped changes three ways and keep merged exec bits, as fossil does
Apply now does what fossil's stash_apply does when a stashed file changed since the stash: it merges the stashed change (pivot: the stash baseline) onto the file on disk with merge_3way, writing fossil's conflict markers and NAME-baseline/-original/-merge on conflict, an empty file for binary content, and reporting each conflict. Previously the stashed bytes were restored verbatim, losing the current change.
Save reverts a file with a pending merge to the checked-out version's executable and symlink bits, and records them in vfile, as fossil's revert does; the stash keeps the merged bits and the pop restores them. checkout.VersionPaths becomes VersionPerms to supply those bits.
The sparse stash-record-only conformance case now expects fossil's result: a file the merge took unchanged keeps the checked-out content after a stash round trip. (user: [email protected], size: 12477) [annotate] [blame] [check-ins using] [more...]
//go:build !js package stash import ( "database/sql" "os" "path/filepath" "reflect" "regexp" "strings" "testing" libdb "github.com/danmestas/go-libfossil/db" "github.com/danmestas/go-libfossil/internal/blob" "github.com/danmestas/go-libfossil/internal/checkout" "github.com/danmestas/go-libfossil/internal/hash" _ "github.com/danmestas/go-libfossil/internal/testdriver" "github.com/danmestas/go-libfossil/simio" ) // testEnv sets up a repo DB with one blob ("hello" in a.txt), a checkout DB // with vvar/vfile/vmerge tables, writes a.txt to disk, and returns both DBs, // the working directory, and the rid+uuid of the stored blob. func testEnv(t *testing.T) (repoDB *sql.DB, ckout *sql.DB, dir string) { t.Helper() dir = t.TempDir() // --- Repo DB --- repoPath := filepath.Join(dir, "repo.fossil") var err error repoDB, err = sql.Open(libdb.RegisteredDriver().Name, repoPath) if err != nil { t.Fatalf("open repo db: %v", err) } t.Cleanup(func() { repoDB.Close() }) repoSchema := []string{ `CREATE TABLE blob( rid INTEGER PRIMARY KEY, rcvid INTEGER, size INTEGER, uuid TEXT UNIQUE NOT NULL, content BLOB, CHECK( length(uuid)>=40 AND rid>0 ) )`, `CREATE TABLE delta( rid INTEGER PRIMARY KEY, srcid INTEGER NOT NULL REFERENCES blob )`, `CREATE INDEX delta_i1 ON delta(srcid)`, `INSERT INTO blob(rid, uuid, size, content) VALUES(0, '0000000000000000000000000000000000000000', 0, NULL)`, } // The CHECK constraint requires rid>0, so we need to insert rid=0 differently. // Actually let's just create schema and use blob.Store. repoSchema2 := []string{ `CREATE TABLE blob( rid INTEGER PRIMARY KEY, rcvid INTEGER, size INTEGER, uuid TEXT UNIQUE NOT NULL, content BLOB )`, `CREATE TABLE delta( rid INTEGER PRIMARY KEY, srcid INTEGER NOT NULL REFERENCES blob )`, `CREATE INDEX delta_i1 ON delta(srcid)`, `CREATE TABLE unclustered(rid INTEGER PRIMARY KEY)`, // Every stored blob names the rcvfrom row of its receipt. `CREATE TABLE rcvfrom( rcvid INTEGER PRIMARY KEY, uid INTEGER, mtime DATETIME, nonce TEXT UNIQUE, ipaddr TEXT )`, // Settings such as allow-symlinks are read from it. `CREATE TABLE config(name TEXT PRIMARY KEY NOT NULL, value CLOB, mtime DATE)`, } _ = repoSchema for _, s := range repoSchema2 { if _, err := repoDB.Exec(s); err != nil { t.Fatalf("repo schema: %v\n SQL: %s", err, s) } } // Store "hello" blob. rid, uuid, err := blob.Store(repoDB, []byte("hello")) if err != nil { t.Fatalf("blob.Store: %v", err) } // --- Checkout DB --- ckoutPath := filepath.Join(dir, ".fslckout") ckout, err = sql.Open(libdb.RegisteredDriver().Name, ckoutPath) if err != nil { t.Fatalf("open ckout db: %v", err) } t.Cleanup(func() { ckout.Close() }) ckoutSchema := []string{ `CREATE TABLE vvar(name TEXT PRIMARY KEY, value TEXT)`, `CREATE TABLE vfile( id INTEGER PRIMARY KEY, vid INTEGER, chnged INTEGER DEFAULT 0, deleted INTEGER DEFAULT 0, isexe BOOLEAN DEFAULT 0, islink BOOLEAN DEFAULT 0, rid INTEGER DEFAULT 0, mrid INTEGER DEFAULT 0, mtime REAL DEFAULT 0, pathname TEXT, origname TEXT, mhash TEXT )`, `CREATE TABLE vmerge(id INTEGER, merge INTEGER, mhash TEXT)`, } for _, s := range ckoutSchema { if _, err := ckout.Exec(s); err != nil { t.Fatalf("ckout schema: %v\n SQL: %s", err, s) } } // Populate vvar and vfile. if _, err := ckout.Exec("INSERT INTO vvar(name,value) VALUES('checkout','1')"); err != nil { t.Fatal(err) } if _, err := ckout.Exec("INSERT INTO vvar(name,value) VALUES('checkout-hash',?)", uuid); err != nil { t.Fatal(err) } if _, err := ckout.Exec("INSERT INTO vfile(id,vid,pathname,rid,mrid) VALUES(1,1,'a.txt',?,?)", int64(rid), int64(rid)); err != nil { t.Fatal(err) } // Write a.txt to disk with baseline content. if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("hello"), 0o644); err != nil { t.Fatal(err) } return repoDB, ckout, dir } // noScan is a Scanner for tests that set vfile by hand: it changes nothing. type noScan struct{} func (noScan) ScanChanges(checkout.ScanFlags) error { return nil } func (noScan) VersionPerms() (map[string]string, error) { return nil, nil } // modifyFile changes a.txt on disk and marks it changed in vfile. func modifyFile(t *testing.T, ckout *sql.DB, dir string) { t.Helper() if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("hello world"), 0o644); err != nil { t.Fatal(err) } if _, err := ckout.Exec("UPDATE vfile SET chnged=1 WHERE pathname='a.txt'"); err != nil { t.Fatal(err) } } func readFile(t *testing.T, path string) string { t.Helper() data, err := os.ReadFile(path) if err != nil { t.Fatalf("readFile(%s): %v", path, err) } return string(data) } func TestSaveAndList(t *testing.T) { repoDB, ckout, dir := testEnv(t) // Modify file. modifyFile(t, ckout, dir) // Save. if err := Save(noScan{}, ckout, repoDB, simio.RealEnv(), dir, "test stash"); err != nil { t.Fatalf("Save: %v", err) } // File should be reverted to baseline. got := readFile(t, filepath.Join(dir, "a.txt")) if got != "hello" { t.Errorf("after Save: a.txt = %q, want %q", got, "hello") } // vfile should show chnged=0. var chnged int if err := ckout.QueryRow("SELECT chnged FROM vfile WHERE pathname='a.txt'").Scan(&chnged); err != nil { t.Fatal(err) } if chnged != 0 { t.Errorf("after Save: vfile chnged = %d, want 0", chnged) } // List should have 1 entry. entries, err := List(ckout) if err != nil { t.Fatalf("List: %v", err) } if len(entries) != 1 { t.Fatalf("List: got %d entries, want 1", len(entries)) } if entries[0].Comment != "test stash" { t.Errorf("List: comment = %q, want %q", entries[0].Comment, "test stash") } // Verify baseline hash is stored. expectedHash := hash.SHA1([]byte("hello")) if entries[0].Hash != expectedHash { // Hash is checkout-hash from vvar, not file hash — it's the manifest UUID. // Just check it's not empty. } } func TestPopRestoresChanges(t *testing.T) { repoDB, ckout, dir := testEnv(t) modifyFile(t, ckout, dir) if err := Save(noScan{}, ckout, repoDB, simio.RealEnv(), dir, "pop test"); err != nil { t.Fatalf("Save: %v", err) } // Verify reverted. if got := readFile(t, filepath.Join(dir, "a.txt")); got != "hello" { t.Fatalf("after Save: a.txt = %q, want %q", got, "hello") } // Pop. if _, err := Pop(ckout, repoDB, simio.RealEnv(), dir); err != nil { t.Fatalf("Pop: %v", err) } // File should have modified content. got := readFile(t, filepath.Join(dir, "a.txt")) if got != "hello world" { t.Errorf("after Pop: a.txt = %q, want %q", got, "hello world") } // List should be empty. entries, err := List(ckout) if err != nil { t.Fatalf("List: %v", err) } if len(entries) != 0 { t.Errorf("after Pop: List has %d entries, want 0", len(entries)) } } func TestApplyKeepsStash(t *testing.T) { repoDB, ckout, dir := testEnv(t) modifyFile(t, ckout, dir) if err := Save(noScan{}, ckout, repoDB, simio.RealEnv(), dir, "apply test"); err != nil { t.Fatalf("Save: %v", err) } // Get the stash ID. entries, err := List(ckout) if err != nil { t.Fatal(err) } if len(entries) != 1 { t.Fatalf("expected 1 entry, got %d", len(entries)) } // Apply. if _, err := Apply(ckout, repoDB, simio.RealEnv(), dir, entries[0].ID); err != nil { t.Fatalf("Apply: %v", err) } // File should have modified content. got := readFile(t, filepath.Join(dir, "a.txt")) if got != "hello world" { t.Errorf("after Apply: a.txt = %q, want %q", got, "hello world") } // List should still have 1 entry. entries, err = List(ckout) if err != nil { t.Fatal(err) } if len(entries) != 1 { t.Errorf("after Apply: List has %d entries, want 1", len(entries)) } } func TestDropAndClear(t *testing.T) { repoDB, ckout, dir := testEnv(t) // Save twice (need to re-modify between saves). modifyFile(t, ckout, dir) if err := Save(noScan{}, ckout, repoDB, simio.RealEnv(), dir, "first"); err != nil { t.Fatalf("Save 1: %v", err) } modifyFile(t, ckout, dir) if err := Save(noScan{}, ckout, repoDB, simio.RealEnv(), dir, "second"); err != nil { t.Fatalf("Save 2: %v", err) } entries, err := List(ckout) if err != nil { t.Fatal(err) } if len(entries) != 2 { t.Fatalf("expected 2 entries, got %d", len(entries)) } // Drop first (oldest = first in list, which is ordered by creation). firstID := entries[0].ID if err := Drop(ckout, firstID); err != nil { t.Fatalf("Drop: %v", err) } entries, err = List(ckout) if err != nil { t.Fatal(err) } if len(entries) != 1 { t.Fatalf("after Drop: expected 1 entry, got %d", len(entries)) } if entries[0].Comment != "second" { t.Errorf("remaining entry comment = %q, want %q", entries[0].Comment, "second") } // Clear. if err := Clear(ckout); err != nil { t.Fatalf("Clear: %v", err) } entries, err = List(ckout) if err != nil { t.Fatal(err) } if len(entries) != 0 { t.Errorf("after Clear: expected 0 entries, got %d", len(entries)) } } // Save cannot yet stash a rename: it records and restores files by their // current name only. It must refuse rather than stash the rename as a plain // edit and lose it (#233). func TestSaveRefusesRename(t *testing.T) { repoDB, ckout, dir := testEnv(t) modifyFile(t, ckout, dir) if _, err := ckout.Exec("UPDATE vfile SET origname='old.txt' WHERE pathname='a.txt'"); err != nil { t.Fatal(err) } err := Save(noScan{}, ckout, repoDB, simio.RealEnv(), dir, "rename") if err == nil || !strings.Contains(err.Error(), "rename") { t.Fatalf("Save over a rename = %v, want a rename refusal", err) } } // chnged 6 through 9 record a change to the executable or symlink bit, not a // merge. Save cannot restore modes yet, so it refuses with an accurate reason // rather than calling it a merge (#233). func TestSaveRefusesModeChangeAccurately(t *testing.T) { repoDB, ckout, dir := testEnv(t) if _, err := ckout.Exec("UPDATE vfile SET chnged=6 WHERE pathname='a.txt'"); err != nil { t.Fatal(err) } err := Save(noScan{}, ckout, repoDB, simio.RealEnv(), dir, "mode") if err == nil || !strings.Contains(err.Error(), "permission") { t.Fatalf("Save over a mode change = %v, want a permission-change refusal", err) } if strings.Contains(err.Error(), "merge") { t.Fatalf("a mode change was reported as a merge: %v", err) } } // A stash's creation time is stored as fossil stores it, a Julian day // (src/stash.c: julianday('now')), and listed as fossil's stash ls shows it, // datetime(ctime). A row fossil wrote lists the same way. func TestStashTimeAsFossil(t *testing.T) { repoDB, ckout, dir := testEnv(t) modifyFile(t, ckout, dir) if err := Save(noScan{}, ckout, repoDB, simio.RealEnv(), dir, "test stash"); err != nil { t.Fatalf("Save: %v", err) } var typ string if err := ckout.QueryRow("SELECT typeof(ctime) FROM stash").Scan(&typ); err != nil { t.Fatal(err) } if typ != "real" { t.Errorf("stash.ctime stored as %s, want a Julian day (real)", typ) } if _, err := ckout.Exec("INSERT INTO stash(stashid, hash, comment, ctime)" + " VALUES(99, '', 'fossil', 2461332.5)"); err != nil { t.Fatal(err) } entries, err := List(ckout) if err != nil { t.Fatalf("List: %v", err) } shape := regexp.MustCompile(`^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d$`) for _, e := range entries { if !shape.MatchString(e.CTime) { t.Errorf("stash %d: CTime %q, want datetime(ctime)'s YYYY-MM-DD HH:MM:SS", e.ID, e.CTime) } if e.ID == 99 && e.CTime != "2026-10-19 00:00:00" { t.Errorf("fossil's stash: CTime %q, want 2026-10-19 00:00:00", e.CTime) } } } // Stashes list in creation order, oldest first, as fossil's stash ls orders // them (ORDER BY ctime); stash ids break a tie. func TestListOrdersByCreationTime(t *testing.T) { _, ckout, _ := testEnv(t) if err := EnsureTables(ckout); err != nil { t.Fatal(err) } for _, row := range []string{ "(1, '', 'b', 2461332.6)", "(2, '', 'a', 2461332.5)", "(3, '', 'd', 2461332.7)", "(4, '', 'c', 2461332.6)", } { if _, err := ckout.Exec("INSERT INTO stash(stashid, hash, comment, ctime) VALUES" + row); err != nil { t.Fatal(err) } } entries, err := List(ckout) if err != nil { t.Fatal(err) } var got []int64 for _, e := range entries { got = append(got, e.ID) } if want := []int64{2, 1, 4, 3}; !reflect.DeepEqual(got, want) { t.Fatalf("List order = %v, want %v", got, want) } }