Artifact 5f60f2d3348b56d0a7b7513f1489b57760763d475bfe39e47c6e4f1771726d1d:
- File
internal/stash/symlink_unix_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: 8615) [annotate] [blame] [check-ins using]
//go:build unix package stash_test import ( "database/sql" "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/manifest" "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" ) // newLinkCheckout commits plain.txt, other.txt and link, a symlink to // plain.txt, with allow-symlinks on, and checks them out on disk. func newLinkCheckout(t *testing.T) *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() }) if err := r.SetConfig("allow-symlinks", "on"); err != nil { t.Fatal(err) } rid, _, err := manifest.Checkin(r, manifest.CheckinOpts{ Files: []manifest.File{ {Name: "plain.txt", Content: []byte("plain\n")}, {Name: "other.txt", Content: []byte("other\n")}, {Name: "link", Content: []byte("plain.txt"), Perm: "l"}, }, Comment: "initial", User: "test", Time: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), }) if err != nil { t.Fatal(err) } env := simio.RealEnv() 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) } 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} } func wantLink(t *testing.T, dir, name, target string) { t.Helper() got, err := os.Readlink(filepath.Join(dir, name)) if err != nil || got != target { t.Fatalf("%s: Readlink = %q, %v; want a symlink to %s", name, got, err, target) } } func wantFile(t *testing.T, dir, name, body string) { t.Helper() p := filepath.Join(dir, name) info, err := os.Lstat(p) if err != nil || !info.Mode().IsRegular() { t.Fatalf("%s: %v, %v; want a regular file", name, info, err) } data, err := os.ReadFile(p) if err != nil || string(data) != body { t.Fatalf("%s = %q, %v; want %q", name, data, err, body) } } // A repointed symlink is stashed as the link it is, as fossil's stash reads // it with RepoFILE and records file_islink; the stash's revert makes it a // link to its committed target again, and Pop repoints it, each time // leaving what the link points to alone. func TestStashRepointedSymlink(t *testing.T) { s := newLinkCheckout(t) wantLink(t, s.dir, "link", "plain.txt") if err := os.Remove(filepath.Join(s.dir, "link")); err != nil { t.Fatal(err) } if err := os.Symlink("other.txt", filepath.Join(s.dir, "link")); err != nil { t.Fatal(err) } if err := stash.Save(s.co, s.ckout, s.repoDB, s.env, s.dir, "repoint"); err != nil { t.Fatal(err) } var isLink int64 if err := s.ckout.QueryRow("SELECT CAST(isLink AS INTEGER) FROM stashfile WHERE newname='link' AND typeof(isLink)='integer'").Scan(&isLink); err != nil { t.Fatal(err) } if isLink != 1 { t.Errorf("stashfile.isLink of link = %d, want 1", isLink) } wantLink(t, s.dir, "link", "plain.txt") wantFile(t, s.dir, "plain.txt", "plain\n") wantFile(t, s.dir, "other.txt", "other\n") if got := s.changes(t); len(got) != 0 { t.Fatalf("changes after stash = %v, want nothing", got) } if _, err := stash.Pop(s.ckout, s.repoDB, s.env, s.dir); err != nil { t.Fatal(err) } wantLink(t, s.dir, "link", "other.txt") wantFile(t, s.dir, "plain.txt", "plain\n") wantFile(t, s.dir, "other.txt", "other\n") if got := s.changes(t); len(got) != 1 || got["link"] != checkout.ChangeModified { t.Fatalf("changes after pop = %v, want link modified", got) } } // fossilLinkCheckout commits with fossil, allow-symlinks on, plain.txt, // elsewhere.txt and link, a symlink to plain.txt, then repoints link to // elsewhere.txt (a name of another length, so fossil's scan sees it at // once). It returns the repository and the checkout. func fossilLinkCheckout(t *testing.T, bin string) (repoPath, ck string) { t.Helper() 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) fossilIn(t, bin, ck, "settings", "allow-symlinks", "on") for name, body := range map[string]string{"plain.txt": "plain\n", "elsewhere.txt": "elsewhere\n"} { if err := os.WriteFile(filepath.Join(ck, name), []byte(body), 0o644); err != nil { t.Fatal(err) } } link := filepath.Join(ck, "link") if err := os.Symlink("plain.txt", link); err != nil { t.Fatal(err) } fossilIn(t, bin, ck, "add", ".") fossilIn(t, bin, ck, "commit", "-m", "v1") if err := os.Remove(link); err != nil { t.Fatal(err) } if err := os.Symlink("elsewhere.txt", link); err != nil { t.Fatal(err) } if got := fossilIn(t, bin, ck, "changes"); got != "EDITED link" { t.Fatalf("fossil changes before save = %q", got) } return repoPath, ck } // A stash of a repointed symlink round-trips between fossil and // go-libfossil: fossil pops what go-libfossil saved and go-libfossil what // fossil saved, each leaving the link repointed and its targets alone. func TestStashSymlinkMatchesFossil(t *testing.T) { bin := testutil.RequireFossilBin(t) env := simio.RealEnv() for _, goSaves := range []bool{true, false} { repoPath, ck := fossilLinkCheckout(t, bin) if goSaves { withDBs(t, ck, repoPath, env, func(co *checkout.Checkout, ckout, repoDB *sql.DB) { if err := stash.Save(co, ckout, repoDB, env, ck, "repoint"); err != nil { t.Fatal(err) } }) } else { fossilIn(t, bin, ck, "stash", "save", "-m", "repoint") } wantLink(t, ck, "link", "plain.txt") if got := fossilIn(t, bin, ck, "changes"); got != "" { t.Fatalf("go saves %v: fossil changes after save = %q", goSaves, got) } if goSaves { fossilIn(t, bin, ck, "stash", "pop") } else { withDBs(t, ck, repoPath, env, func(_ *checkout.Checkout, ckout, repoDB *sql.DB) { if _, err := stash.Pop(ckout, repoDB, env, ck); err != nil { t.Fatal(err) } }) } wantLink(t, ck, "link", "elsewhere.txt") wantFile(t, ck, "plain.txt", "plain\n") wantFile(t, ck, "elsewhere.txt", "elsewhere\n") if got := fossilIn(t, bin, ck, "changes"); got != "EDITED link" { t.Fatalf("go saves %v: fossil changes after pop = %q", goSaves, got) } fossilIn(t, bin, ck, "close", "--force") } } // Pop over a symlink that changed since the stash was saved does not merge // it: fossil's stash_apply prints "Cannot merge symlink", counts a // conflict and writes nothing, and pop still drops the stash. func TestPopDoesNotMergeChangedSymlink(t *testing.T) { for _, tc := range []struct { name string after func(t *testing.T, p string) // what replaces link before Pop check func(t *testing.T, dir string) }{ {"repointed again", func(t *testing.T, p string) { if err := os.Remove(p); err != nil { t.Fatal(err) } if err := os.Symlink("third.txt", p); err != nil { t.Fatal(err) } }, func(t *testing.T, dir string) { wantLink(t, dir, "link", "third.txt") }}, {"now a plain file", func(t *testing.T, p string) { if err := os.Remove(p); err != nil { t.Fatal(err) } if err := os.WriteFile(p, []byte("plain.txt"), 0o644); err != nil { t.Fatal(err) } }, func(t *testing.T, dir string) { wantFile(t, dir, "link", "plain.txt") }}, } { t.Run(tc.name, func(t *testing.T) { s := newLinkCheckout(t) link := filepath.Join(s.dir, "link") if err := os.Remove(link); err != nil { t.Fatal(err) } if err := os.Symlink("other.txt", link); err != nil { t.Fatal(err) } if err := stash.Save(s.co, s.ckout, s.repoDB, s.env, s.dir, "repoint"); err != nil { t.Fatal(err) } tc.after(t, link) conflicts, err := stash.Pop(s.ckout, s.repoDB, s.env, s.dir) if err != nil { t.Fatal(err) } if len(conflicts) != 1 || conflicts[0] != "link" { t.Errorf("conflicts = %q, want [link]", conflicts) } tc.check(t, s.dir) wantFile(t, s.dir, "plain.txt", "plain\n") wantFile(t, s.dir, "other.txt", "other\n") entries, err := stash.List(s.ckout) if err != nil || len(entries) != 0 { t.Fatalf("stash after pop = %v, %v; want it dropped", entries, err) } }) } }