Artifact 30c7ba8ffa6d389a53d9d1982235993e190d7b7561127284d8c7c4bb30c2bc14:
- File
internal/stash/storage_test.go
— part of check-in
[2ee5684bf3]
at
2026-10-20 01:42:32
on branch feat/submanifest-reader
— fix(stash): do not merge a changed symlink, as fossil's stash apply does
Where a symlink was stashed or is now on disk, Apply writes the stashed file only when both are links and the link still holds the baseline. Otherwise it leaves the file and its row alone and reports it, as fossil's stash_apply prints "Cannot merge symlink" and counts a conflict. Apply and Pop now return the files they did not merge; Pop still drops the entry, as fossil's does. (user: [email protected], size: 10745) [annotate] [blame] [check-ins using] [more...]
//go:build !js package stash_test import ( "database/sql" "path/filepath" "testing" "time" libdb "github.com/danmestas/go-libfossil/db" "github.com/danmestas/go-libfossil/internal/checkout" "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" ) // 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) } }