Artifact 162ed6b0d0aad79e22023746c38c599b7c665efed419da7d95a56c631e50d94d:
- File
internal/stash/apply_fossil_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: 12609) [annotate] [blame] [check-ins using] [more...]
//go:build !js package stash_test import ( "database/sql" "errors" "fmt" "os" "os/exec" "path/filepath" "slices" "sort" "strings" "testing" "time" libdb "github.com/danmestas/go-libfossil/db" "github.com/danmestas/go-libfossil/internal/checkout" "github.com/danmestas/go-libfossil/internal/cone" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" "github.com/danmestas/go-libfossil/internal/repo" "github.com/danmestas/go-libfossil/internal/stash" "github.com/danmestas/go-libfossil/simio" "github.com/danmestas/go-libfossil/testutil" ) // twinCheckouts is one repository with two checkouts: fossil works in ckF, // go-libfossil in ckG. type twinCheckouts struct { bin, repoPath, ckF, ckG string env *simio.Env } // newTwinCheckouts commits files to a new repository from ckF and opens ckG // on the same version. func newTwinCheckouts(t *testing.T, files map[string]string) twinCheckouts { t.Helper() tw := twinCheckouts{bin: testutil.RequireFossilBin(t)} tw.env = &simio.Env{ Clock: simio.NewSimClockAt(time.Now().Add(time.Hour)), Rand: simio.CryptoRand{}, Storage: simio.OSStorage{}, } dir := t.TempDir() tw.repoPath = filepath.Join(dir, "r.fossil") tw.ckF, tw.ckG = filepath.Join(dir, "ckf"), filepath.Join(dir, "ckg") for _, ck := range []string{tw.ckF, tw.ckG} { if err := os.Mkdir(ck, 0o755); err != nil { t.Fatal(err) } } fossilIn(t, tw.bin, dir, "init", tw.repoPath) fossilIn(t, tw.bin, tw.ckF, "open", tw.repoPath) names := make([]string, 0, len(files)) for name, body := range files { writeIn(t, tw.ckF, name, body) names = append(names, name) } fossilIn(t, tw.bin, tw.ckF, append([]string{"add"}, names...)...) fossilIn(t, tw.bin, tw.ckF, "commit", "-m", "base", "--no-warnings") fossilIn(t, tw.bin, tw.ckG, "open", tw.repoPath) return tw } // both writes files into both checkouts. func (tw twinCheckouts) both(t *testing.T, files map[string]string) { t.Helper() for _, ck := range []string{tw.ckF, tw.ckG} { for name, body := range files { writeIn(t, ck, name, body) } } } // save stashes with fossil in ckF and with go-libfossil in ckG. func (tw twinCheckouts) save(t *testing.T) { t.Helper() fossilIn(t, tw.bin, tw.ckF, "stash", "save", "-m", "s") withDBs(t, tw.ckG, tw.repoPath, tw.env, func(co *checkout.Checkout, ckout, repoDB *sql.DB) { if err := stash.Save(co, ckout, repoDB, tw.env, tw.ckG, "s"); err != nil { t.Fatal(err) } }) } // pop pops with fossil in ckF and with go-libfossil in ckG, and returns the // files each reported in conflict. func (tw twinCheckouts) pop(t *testing.T) (fossilConflicts, goConflicts []string) { t.Helper() cmd := exec.Command(tw.bin, "stash", "pop") cmd.Dir = tw.ckF out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("fossil stash pop: %v\n%s", err, out) } for _, line := range strings.Split(string(out), "\n") { if name, ok := strings.CutPrefix(line, "CONFLICT "); ok { fossilConflicts = append(fossilConflicts, name) } } withDBs(t, tw.ckG, tw.repoPath, tw.env, func(co *checkout.Checkout, ckout, repoDB *sql.DB) { if goConflicts, err = stash.Pop(ckout, repoDB, tw.env, tw.ckG); err != nil { t.Fatal(err) } }) sort.Strings(fossilConflicts) sort.Strings(goConflicts) return fossilConflicts, goConflicts } // tree renders every file in ck but the checkout database: name, mode and // bytes. func tree(t *testing.T, ck string) string { t.Helper() entries, err := os.ReadDir(ck) if err != nil { t.Fatal(err) } var b strings.Builder for _, e := range entries { if e.Name() == ".fslckout" { continue } info, err := os.Lstat(filepath.Join(ck, e.Name())) if err != nil { t.Fatal(err) } data, err := os.ReadFile(filepath.Join(ck, e.Name())) if err != nil { t.Fatal(err) } fmt.Fprintf(&b, "%s %v %q\n", e.Name(), info.Mode().Perm(), data) } return b.String() } // requireSameTrees compares the working files and what fossil's scan reports. func (tw twinCheckouts) requireSameTrees(t *testing.T, when string) { t.Helper() requireSame(t, when+": working files", tree(t, tw.ckF), tree(t, tw.ckG)) requireSame(t, when+": fossil changes", fossilIn(t, tw.bin, tw.ckF, "changes"), fossilIn(t, tw.bin, tw.ckG, "changes")) } // Fossil's stash_apply merges a stashed edit three ways onto a file changed // since the stash was taken (pivot: the stash's baseline; mine: the file on // disk; theirs: the stashed content): a clean merge keeps both changes, an // overlap is written with fossil's conflict markers and the three inputs // kept beside it as NAME-baseline, NAME-original and NAME-merge, and binary // content cannot be merged, so the file is left empty. Each conflict is // reported; the pop still succeeds and drops the stash. func TestStashApplyMergesChangedFilesMatchesFossil(t *testing.T) { tw := newTwinCheckouts(t, map[string]string{ "m.txt": "1\n2\n3\n4\n5\n", "n.txt": "x\n", "b.bin": "\x00bin\n", }) tw.both(t, map[string]string{ "m.txt": "1\nSS\n3\n4\n5\n", "n.txt": "stashed\n", "b.bin": "\x00binstash\n", }) tw.save(t) tw.requireSameTrees(t, "after save") tw.both(t, map[string]string{ "m.txt": "1\n2\n3\n4\nCCC\n", "n.txt": "current!\n", "b.bin": "\x00bincurrent\n", }) fc, gc := tw.pop(t) requireSame(t, "conflicts", strings.Join(fc, "\n"), strings.Join(gc, "\n")) if !slices.Equal(fc, []string{"b.bin", "n.txt"}) { t.Fatalf("fossil conflicts = %q", fc) } tw.requireSameTrees(t, "after pop") requireSame(t, "stash", query(t, tw.ckF, stashRows), query(t, tw.ckG, stashRows)) } // A stash popped after an update to a version that changed the file merges // the stashed edit onto the updated file, as fossil's does. func TestStashApplyAfterUpdateMatchesFossil(t *testing.T) { tw := newTwinCheckouts(t, map[string]string{"m.txt": "1\n2\n3\n4\n5\n"}) base := artifactOf(t, tw.bin, tw.ckF, "trunk") writeIn(t, tw.ckF, "m.txt", "1\n2\n3\n4\nNEXT\n") fossilIn(t, tw.bin, tw.ckF, "commit", "-m", "next", "--no-warnings") fossilIn(t, tw.bin, tw.ckF, "update", base) tw.both(t, map[string]string{"m.txt": "1\nSTASHED\n3\n4\n5\n"}) tw.save(t) for _, ck := range []string{tw.ckF, tw.ckG} { fossilIn(t, tw.bin, ck, "update", "trunk") } fc, gc := tw.pop(t) if len(fc) != 0 || len(gc) != 0 { t.Fatalf("conflicts: fossil %q, go-libfossil %q", fc, gc) } tw.requireSameTrees(t, "after pop") if got := tree(t, tw.ckG); !strings.Contains(got, `"1\nSTASHED\n3\n4\nNEXT\n"`) { t.Fatalf("pop after update lost a change:\n%s", got) } } // A merge that changes only a file's executable bit leaves the row's isexe // set with no other change. Stashing an edit to that file reverts it as // fossil's revert does, with the checked-out version's bit (and isexe), and // records the merged bit, which the pop restores. func TestStashMergedExecBitMatchesFossil(t *testing.T) { tw := newTwinCheckouts(t, map[string]string{"a.sh": "a\n", "b.txt": "b\n"}) fossilIn(t, tw.bin, tw.ckF, "branch", "new", "br", "trunk") fossilIn(t, tw.bin, tw.ckF, "update", "br") if err := os.Chmod(filepath.Join(tw.ckF, "a.sh"), 0o755); err != nil { t.Fatal(err) } fossilIn(t, tw.bin, tw.ckF, "commit", "-m", "exec on br", "--no-warnings") fossilIn(t, tw.bin, tw.ckF, "update", "trunk") writeIn(t, tw.ckF, "b.txt", "trunk b\n") fossilIn(t, tw.bin, tw.ckF, "commit", "-m", "on trunk", "--no-warnings") fossilIn(t, tw.bin, tw.ckG, "update", "trunk") for _, ck := range []string{tw.ckF, tw.ckG} { fossilIn(t, tw.bin, ck, "merge", "br") } tw.both(t, map[string]string{"a.sh": "a edited\n"}) const rows = "SELECT pathname, rid, mrid, chnged, isexe FROM vfile ORDER BY pathname" const stashExec = "SELECT newname, CAST(isExec AS INT) FROM stashfile ORDER BY newname" tw.save(t) requireSame(t, "vfile after save", query(t, tw.ckF, rows), query(t, tw.ckG, rows)) requireSame(t, "stash after save", query(t, tw.ckF, stashExec), query(t, tw.ckG, stashExec)) tw.requireSameTrees(t, "after save") tw.pop(t) tw.requireSameTrees(t, "after pop") requireSame(t, "vfile after pop and scan", query(t, tw.ckF, rows), query(t, tw.ckG, rows)) if got := tree(t, tw.ckG); !strings.Contains(got, "a.sh -rwxr-xr-x") { t.Fatalf("pop lost the merged executable bit:\n%s", got) } } // In a sparse checkout, a merge that changes only executable bits is // recorded for a file outside the cone without writing it (sparse_pending), // and applied to vfile for one inside. Stashing an edit to the file inside // reverts it with the checked-out version's bit and the pop restores the // merged one, as fossil's do; the record outside the cone is left as it is. func TestStashMergedExecBitSparse(t *testing.T) { bin := testutil.RequireFossilBin(t) env := &simio.Env{ Clock: simio.NewSimClockAt(time.Now().Add(time.Hour)), Rand: simio.CryptoRand{}, Storage: simio.OSStorage{}, } dir := t.TempDir() repoPath := filepath.Join(dir, "r.fossil") ck := filepath.Join(dir, "ck") if err := os.Mkdir(ck, 0o755); err != nil { t.Fatal(err) } fossilIn(t, bin, dir, "init", repoPath) fossilIn(t, bin, ck, "open", repoPath) for _, sub := range []string{"src", "tools"} { if err := os.Mkdir(filepath.Join(ck, sub), 0o755); err != nil { t.Fatal(err) } } writeIn(t, ck, "src/x.sh", "x\n") writeIn(t, ck, "src/a.c", "a\n") writeIn(t, ck, "tools/t.sh", "t\n") fossilIn(t, bin, ck, "add", "src/x.sh", "src/a.c", "tools/t.sh") fossilIn(t, bin, ck, "commit", "-m", "base", "--no-warnings") fossilIn(t, bin, ck, "branch", "new", "br", "trunk") fossilIn(t, bin, ck, "update", "br") for _, name := range []string{"src/x.sh", "tools/t.sh"} { if err := os.Chmod(filepath.Join(ck, name), 0o755); err != nil { t.Fatal(err) } } fossilIn(t, bin, ck, "commit", "-m", "exec on br", "--no-warnings") fossilIn(t, bin, ck, "update", "trunk") writeIn(t, ck, "src/a.c", "trunk a\n") fossilIn(t, bin, ck, "commit", "-m", "on trunk", "--no-warnings") trunkUUID, brUUID := artifactOf(t, bin, ck, "trunk"), artifactOf(t, bin, ck, "br") fossilIn(t, bin, ck, "close") if err := os.RemoveAll(ck); err != nil { t.Fatal(err) } r, err := repo.Open(repoPath) if err != nil { t.Fatal(err) } defer r.Close() var trunk, br libfossil.FslID for uuid, rid := range map[string]*libfossil.FslID{trunkUUID: &trunk, brUUID: &br} { if err := r.DB().QueryRow("SELECT rid FROM blob WHERE uuid=?", uuid).Scan(rid); err != nil { t.Fatal(err) } } src, err := cone.New([]string{"src"}, nil) if err != nil { t.Fatal(err) } co, err := checkout.Create(r, ck, checkout.CreateOpts{Env: env, Cone: src}) if err != nil { t.Fatal(err) } defer co.Close() if err := co.Extract(trunk, checkout.ExtractOpts{}); err != nil { t.Fatal(err) } if _, err := co.Merge(checkout.MergeOpts{Version: br}); err != nil { t.Fatal(err) } writeIn(t, ck, "src/x.sh", "x edited\n") if err := os.Chmod(filepath.Join(ck, "src/x.sh"), 0o755); err != nil { t.Fatal(err) } ckName, err := checkout.FindCheckoutDB(simio.OSStorage{}, ck, false) if err != nil { t.Fatal(err) } ckout, err := libdb.OpenSQL(ckName, libdb.OpenConfig{}, nil) if err != nil { t.Fatal(err) } defer ckout.Close() const pending = "SELECT pathname, rid, mrid, chnged, deleted, isexe FROM sparse_pending ORDER BY pathname" const rows = "SELECT pathname, chnged, isexe FROM vfile ORDER BY pathname" before := query(t, ck, pending) if !strings.Contains(before, "tools/t.sh") { t.Fatalf("merge recorded no out-of-cone outcome:\n%s", before) } if got := query(t, ck, rows); !strings.Contains(got, "src/x.sh|0|1\n") { t.Fatalf("merge did not set src/x.sh's bit alone:\n%s", got) } if err := stash.Save(co, ckout, r.DB().SqlDB(), env, ck, "sparse"); err != nil { t.Fatal(err) } requireMode(t, filepath.Join(ck, "src/x.sh"), 0o644) if got := query(t, ck, rows); !strings.Contains(got, "src/x.sh|0|0\n") { t.Fatalf("save left the merged bit on src/x.sh's row:\n%s", got) } requireSame(t, "record-only merge after save", before, query(t, ck, pending)) if _, err := stash.Pop(ckout, r.DB().SqlDB(), env, ck); err != nil { t.Fatal(err) } requireMode(t, filepath.Join(ck, "src/x.sh"), 0o755) requireSame(t, "record-only merge after pop", before, query(t, ck, pending)) if _, err := os.Lstat(filepath.Join(ck, "tools")); !errors.Is(err, os.ErrNotExist) { t.Fatalf("stash wrote outside the cone: %v", err) } } func requireMode(t *testing.T, path string, want os.FileMode) { t.Helper() info, err := os.Stat(path) if err != nil { t.Fatal(err) } if got := info.Mode().Perm(); got != want { t.Fatalf("%s: mode %v, want %v", path, got, want) } }