Files
sgard/garden/garden_test.go
Kyle Isom b4bfce1291 Add directory recursion for Add and mirror up/down commands.
Add now recursively walks directories instead of creating a single
"directory" type entry. Extract addEntry helper for reuse. Implement
MirrorUp (sync filesystem state into manifest) and MirrorDown (sync
manifest state to filesystem with untracked file cleanup). Add CLI
mirror command with up/down subcommands.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 23:42:58 -07:00

661 lines
16 KiB
Go

package garden
import (
"os"
"path/filepath"
"testing"
)
func TestInitCreatesStructure(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
// manifest.yaml should exist
if _, err := os.Stat(filepath.Join(repoDir, "manifest.yaml")); err != nil {
t.Errorf("manifest.yaml not found: %v", err)
}
// blobs/ directory should exist
if _, err := os.Stat(filepath.Join(repoDir, "blobs")); err != nil {
t.Errorf("blobs/ not found: %v", err)
}
// .gitignore should exist and exclude blobs/
gitignore, err := os.ReadFile(filepath.Join(repoDir, ".gitignore"))
if err != nil {
t.Errorf(".gitignore not found: %v", err)
} else if string(gitignore) != "blobs/\n" {
t.Errorf(".gitignore content = %q, want %q", gitignore, "blobs/\n")
}
if g.manifest.Version != 1 {
t.Errorf("expected version 1, got %d", g.manifest.Version)
}
if len(g.manifest.Files) != 0 {
t.Errorf("expected 0 files, got %d", len(g.manifest.Files))
}
}
func TestInitRejectsExisting(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
if _, err := Init(repoDir); err != nil {
t.Fatalf("first Init: %v", err)
}
if _, err := Init(repoDir); err == nil {
t.Fatal("second Init should fail on existing repo")
}
}
func TestOpenLoadsRepo(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
if _, err := Init(repoDir); err != nil {
t.Fatalf("Init: %v", err)
}
g, err := Open(repoDir)
if err != nil {
t.Fatalf("Open: %v", err)
}
if g.manifest.Version != 1 {
t.Errorf("expected version 1, got %d", g.manifest.Version)
}
}
func TestAddFile(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
// Create a file to add.
testFile := filepath.Join(root, "testfile")
if err := os.WriteFile(testFile, []byte("hello world\n"), 0o644); err != nil {
t.Fatalf("writing test file: %v", err)
}
if err := g.Add([]string{testFile}); err != nil {
t.Fatalf("Add: %v", err)
}
if len(g.manifest.Files) != 1 {
t.Fatalf("expected 1 file, got %d", len(g.manifest.Files))
}
entry := g.manifest.Files[0]
if entry.Type != "file" {
t.Errorf("expected type file, got %s", entry.Type)
}
if entry.Hash == "" {
t.Error("expected non-empty hash")
}
if entry.Mode != "0644" {
t.Errorf("expected mode 0644, got %s", entry.Mode)
}
// Verify the blob was stored.
if !g.store.Exists(entry.Hash) {
t.Error("blob not found in store")
}
// Verify manifest was persisted.
g2, err := Open(repoDir)
if err != nil {
t.Fatalf("re-Open: %v", err)
}
if len(g2.manifest.Files) != 1 {
t.Errorf("persisted manifest has %d files, want 1", len(g2.manifest.Files))
}
}
func TestAddDirectory(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
testDir := filepath.Join(root, "testdir")
if err := os.Mkdir(testDir, 0o755); err != nil {
t.Fatalf("creating test dir: %v", err)
}
testFile := filepath.Join(testDir, "inside.txt")
if err := os.WriteFile(testFile, []byte("inside"), 0o644); err != nil {
t.Fatalf("writing file inside dir: %v", err)
}
if err := g.Add([]string{testDir}); err != nil {
t.Fatalf("Add: %v", err)
}
if len(g.manifest.Files) != 1 {
t.Fatalf("expected 1 file, got %d", len(g.manifest.Files))
}
entry := g.manifest.Files[0]
if entry.Type != "file" {
t.Errorf("expected type file, got %s", entry.Type)
}
if entry.Hash == "" {
t.Error("expected non-empty hash")
}
expectedPath := toTildePath(testFile)
if entry.Path != expectedPath {
t.Errorf("expected path %s, got %s", expectedPath, entry.Path)
}
}
func TestAddSymlink(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
// Create a target and a symlink to it.
target := filepath.Join(root, "target")
if err := os.WriteFile(target, []byte("target content"), 0o644); err != nil {
t.Fatalf("writing target: %v", err)
}
link := filepath.Join(root, "link")
if err := os.Symlink(target, link); err != nil {
t.Fatalf("creating symlink: %v", err)
}
if err := g.Add([]string{link}); err != nil {
t.Fatalf("Add: %v", err)
}
entry := g.manifest.Files[0]
if entry.Type != "link" {
t.Errorf("expected type link, got %s", entry.Type)
}
if entry.Target != target {
t.Errorf("expected target %s, got %s", target, entry.Target)
}
if entry.Hash != "" {
t.Error("symlinks should have no hash")
}
}
func TestAddDuplicateRejected(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
testFile := filepath.Join(root, "testfile")
if err := os.WriteFile(testFile, []byte("data"), 0o644); err != nil {
t.Fatalf("writing test file: %v", err)
}
if err := g.Add([]string{testFile}); err != nil {
t.Fatalf("first Add: %v", err)
}
if err := g.Add([]string{testFile}); err == nil {
t.Fatal("second Add of same path should fail")
}
}
func TestHashFile(t *testing.T) {
root := t.TempDir()
testFile := filepath.Join(root, "testfile")
if err := os.WriteFile(testFile, []byte("hello"), 0o644); err != nil {
t.Fatalf("writing test file: %v", err)
}
hash, err := HashFile(testFile)
if err != nil {
t.Fatalf("HashFile: %v", err)
}
// SHA-256 of "hello"
expected := "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
if hash != expected {
t.Errorf("expected %s, got %s", expected, hash)
}
}
func TestCheckpointDetectsChanges(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
testFile := filepath.Join(root, "testfile")
if err := os.WriteFile(testFile, []byte("original"), 0o644); err != nil {
t.Fatalf("writing test file: %v", err)
}
if err := g.Add([]string{testFile}); err != nil {
t.Fatalf("Add: %v", err)
}
origHash := g.manifest.Files[0].Hash
// Modify the file.
if err := os.WriteFile(testFile, []byte("modified"), 0o644); err != nil {
t.Fatalf("modifying test file: %v", err)
}
if err := g.Checkpoint("test checkpoint"); err != nil {
t.Fatalf("Checkpoint: %v", err)
}
if g.manifest.Files[0].Hash == origHash {
t.Error("checkpoint did not update hash for modified file")
}
if g.manifest.Message != "test checkpoint" {
t.Errorf("expected message 'test checkpoint', got %q", g.manifest.Message)
}
// Verify new blob exists in store.
if !g.store.Exists(g.manifest.Files[0].Hash) {
t.Error("new blob not found in store")
}
// Verify manifest persisted.
g2, err := Open(repoDir)
if err != nil {
t.Fatalf("re-Open: %v", err)
}
if g2.manifest.Files[0].Hash == origHash {
t.Error("persisted manifest still has old hash")
}
}
func TestCheckpointUnchangedFile(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
testFile := filepath.Join(root, "testfile")
if err := os.WriteFile(testFile, []byte("same"), 0o644); err != nil {
t.Fatalf("writing test file: %v", err)
}
if err := g.Add([]string{testFile}); err != nil {
t.Fatalf("Add: %v", err)
}
origHash := g.manifest.Files[0].Hash
origUpdated := g.manifest.Files[0].Updated
if err := g.Checkpoint(""); err != nil {
t.Fatalf("Checkpoint: %v", err)
}
if g.manifest.Files[0].Hash != origHash {
t.Error("hash should not change for unmodified file")
}
if !g.manifest.Files[0].Updated.Equal(origUpdated) {
t.Error("entry timestamp should not change for unmodified file")
}
}
func TestCheckpointMissingFileSkipped(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
testFile := filepath.Join(root, "testfile")
if err := os.WriteFile(testFile, []byte("data"), 0o644); err != nil {
t.Fatalf("writing test file: %v", err)
}
if err := g.Add([]string{testFile}); err != nil {
t.Fatalf("Add: %v", err)
}
// Remove the file before checkpoint.
_ = os.Remove(testFile)
// Checkpoint should not fail.
if err := g.Checkpoint(""); err != nil {
t.Fatalf("Checkpoint should not fail for missing file: %v", err)
}
}
func TestStatusReportsCorrectly(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
// Create and add two files.
okFile := filepath.Join(root, "okfile")
if err := os.WriteFile(okFile, []byte("unchanged"), 0o644); err != nil {
t.Fatalf("writing ok file: %v", err)
}
modFile := filepath.Join(root, "modfile")
if err := os.WriteFile(modFile, []byte("original"), 0o644); err != nil {
t.Fatalf("writing mod file: %v", err)
}
missingFile := filepath.Join(root, "missingfile")
if err := os.WriteFile(missingFile, []byte("will vanish"), 0o644); err != nil {
t.Fatalf("writing missing file: %v", err)
}
if err := g.Add([]string{okFile, modFile, missingFile}); err != nil {
t.Fatalf("Add: %v", err)
}
// Modify one file, remove another.
if err := os.WriteFile(modFile, []byte("changed"), 0o644); err != nil {
t.Fatalf("modifying file: %v", err)
}
_ = os.Remove(missingFile)
statuses, err := g.Status()
if err != nil {
t.Fatalf("Status: %v", err)
}
if len(statuses) != 3 {
t.Fatalf("expected 3 statuses, got %d", len(statuses))
}
stateMap := make(map[string]string)
for _, s := range statuses {
stateMap[s.Path] = s.State
}
okPath := toTildePath(okFile)
modPath := toTildePath(modFile)
missingPath := toTildePath(missingFile)
if stateMap[okPath] != "ok" {
t.Errorf("okfile: expected ok, got %s", stateMap[okPath])
}
if stateMap[modPath] != "modified" {
t.Errorf("modfile: expected modified, got %s", stateMap[modPath])
}
if stateMap[missingPath] != "missing" {
t.Errorf("missingfile: expected missing, got %s", stateMap[missingPath])
}
}
func TestRestoreFile(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
// Create a file and add it.
testFile := filepath.Join(root, "testfile")
content := []byte("restore me\n")
if err := os.WriteFile(testFile, content, 0o644); err != nil {
t.Fatalf("writing test file: %v", err)
}
if err := g.Add([]string{testFile}); err != nil {
t.Fatalf("Add: %v", err)
}
// Delete the file, then restore it.
_ = os.Remove(testFile)
if err := g.Restore(nil, true, nil); err != nil {
t.Fatalf("Restore: %v", err)
}
got, err := os.ReadFile(testFile)
if err != nil {
t.Fatalf("reading restored file: %v", err)
}
if string(got) != string(content) {
t.Errorf("restored content = %q, want %q", got, content)
}
}
func TestRestorePermissions(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
testFile := filepath.Join(root, "secret")
if err := os.WriteFile(testFile, []byte("secret"), 0o600); err != nil {
t.Fatalf("writing test file: %v", err)
}
if err := g.Add([]string{testFile}); err != nil {
t.Fatalf("Add: %v", err)
}
_ = os.Remove(testFile)
if err := g.Restore(nil, true, nil); err != nil {
t.Fatalf("Restore: %v", err)
}
info, err := os.Stat(testFile)
if err != nil {
t.Fatalf("stat restored file: %v", err)
}
if info.Mode().Perm() != 0o600 {
t.Errorf("permissions = %04o, want 0600", info.Mode().Perm())
}
}
func TestRestoreSymlink(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
target := filepath.Join(root, "target")
if err := os.WriteFile(target, []byte("target"), 0o644); err != nil {
t.Fatalf("writing target: %v", err)
}
link := filepath.Join(root, "link")
if err := os.Symlink(target, link); err != nil {
t.Fatalf("creating symlink: %v", err)
}
if err := g.Add([]string{link}); err != nil {
t.Fatalf("Add: %v", err)
}
_ = os.Remove(link)
if err := g.Restore(nil, true, nil); err != nil {
t.Fatalf("Restore: %v", err)
}
got, err := os.Readlink(link)
if err != nil {
t.Fatalf("readlink: %v", err)
}
if got != target {
t.Errorf("symlink target = %q, want %q", got, target)
}
}
func TestRestoreCreatesParentDirs(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
// Create nested file.
nested := filepath.Join(root, "a", "b", "c")
if err := os.MkdirAll(filepath.Dir(nested), 0o755); err != nil {
t.Fatalf("creating dirs: %v", err)
}
if err := os.WriteFile(nested, []byte("deep"), 0o644); err != nil {
t.Fatalf("writing nested file: %v", err)
}
if err := g.Add([]string{nested}); err != nil {
t.Fatalf("Add: %v", err)
}
// Remove the entire directory tree.
_ = os.RemoveAll(filepath.Join(root, "a"))
if err := g.Restore(nil, true, nil); err != nil {
t.Fatalf("Restore: %v", err)
}
got, err := os.ReadFile(nested)
if err != nil {
t.Fatalf("reading restored nested file: %v", err)
}
if string(got) != "deep" {
t.Errorf("content = %q, want %q", got, "deep")
}
}
func TestRestoreSelectivePaths(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
file1 := filepath.Join(root, "file1")
file2 := filepath.Join(root, "file2")
if err := os.WriteFile(file1, []byte("one"), 0o644); err != nil {
t.Fatalf("writing file1: %v", err)
}
if err := os.WriteFile(file2, []byte("two"), 0o644); err != nil {
t.Fatalf("writing file2: %v", err)
}
if err := g.Add([]string{file1, file2}); err != nil {
t.Fatalf("Add: %v", err)
}
_ = os.Remove(file1)
_ = os.Remove(file2)
// Restore only file1.
if err := g.Restore([]string{file1}, true, nil); err != nil {
t.Fatalf("Restore: %v", err)
}
if _, err := os.Stat(file1); err != nil {
t.Error("file1 should have been restored")
}
if _, err := os.Stat(file2); err == nil {
t.Error("file2 should NOT have been restored")
}
}
func TestRestoreConfirmSkips(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
testFile := filepath.Join(root, "testfile")
if err := os.WriteFile(testFile, []byte("original"), 0o644); err != nil {
t.Fatalf("writing test file: %v", err)
}
if err := g.Add([]string{testFile}); err != nil {
t.Fatalf("Add: %v", err)
}
// Overwrite with newer content (file mtime will be >= manifest updated).
if err := os.WriteFile(testFile, []byte("newer on disk"), 0o644); err != nil {
t.Fatalf("modifying test file: %v", err)
}
// Confirm returns false — should skip the file.
alwaysNo := func(path string) bool { return false }
if err := g.Restore(nil, false, alwaysNo); err != nil {
t.Fatalf("Restore: %v", err)
}
got, err := os.ReadFile(testFile)
if err != nil {
t.Fatalf("reading file: %v", err)
}
if string(got) != "newer on disk" {
t.Error("file should not have been overwritten when confirm returns false")
}
}
func TestExpandTildePath(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Skipf("cannot get home dir: %v", err)
}
tests := []struct {
input string
want string
}{
{"~", home},
{"~/foo", filepath.Join(home, "foo")},
{"~/.config/nvim", filepath.Join(home, ".config/nvim")},
{"/tmp/foo", "/tmp/foo"},
}
for _, tt := range tests {
got, err := ExpandTildePath(tt.input)
if err != nil {
t.Errorf("ExpandTildePath(%q): %v", tt.input, err)
continue
}
if got != tt.want {
t.Errorf("ExpandTildePath(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}