package garden import ( "crypto/sha256" "encoding/hex" "os" "path/filepath" "testing" "github.com/kisom/sgard/manifest" ) 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/\ntags\n" { t.Errorf(".gitignore content = %q, want %q", gitignore, "blobs/\ntags\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 TestGetManifest(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("hello"), 0o644); err != nil { t.Fatalf("writing test file: %v", err) } if err := g.Add([]string{testFile}); err != nil { t.Fatalf("Add: %v", err) } m := g.GetManifest() if m == nil { t.Fatal("GetManifest returned nil") } if len(m.Files) != 1 { t.Errorf("expected 1 entry, got %d", len(m.Files)) } } func TestBlobExists(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("blob exists test"), 0o644); err != nil { t.Fatalf("writing test file: %v", err) } if err := g.Add([]string{testFile}); err != nil { t.Fatalf("Add: %v", err) } hash := g.GetManifest().Files[0].Hash if !g.BlobExists(hash) { t.Error("BlobExists returned false for a stored blob") } if g.BlobExists("0000000000000000000000000000000000000000000000000000000000000000") { t.Error("BlobExists returned true for a fake hash") } } func TestReadBlob(t *testing.T) { root := t.TempDir() repoDir := filepath.Join(root, "repo") g, err := Init(repoDir) if err != nil { t.Fatalf("Init: %v", err) } content := []byte("read blob test content") testFile := filepath.Join(root, "testfile") 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) } hash := g.GetManifest().Files[0].Hash got, err := g.ReadBlob(hash) if err != nil { t.Fatalf("ReadBlob: %v", err) } if string(got) != string(content) { t.Errorf("ReadBlob content = %q, want %q", got, content) } } func TestWriteBlob(t *testing.T) { root := t.TempDir() repoDir := filepath.Join(root, "repo") g, err := Init(repoDir) if err != nil { t.Fatalf("Init: %v", err) } data := []byte("write blob test data") hash, err := g.WriteBlob(data) if err != nil { t.Fatalf("WriteBlob: %v", err) } // Verify the hash is correct SHA-256. sum := sha256.Sum256(data) wantHash := hex.EncodeToString(sum[:]) if hash != wantHash { t.Errorf("WriteBlob hash = %s, want %s", hash, wantHash) } if !g.BlobExists(hash) { t.Error("BlobExists returned false after WriteBlob") } } func TestReplaceManifest(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 new manifest with a custom entry. newManifest := manifest.New() newManifest.Files = append(newManifest.Files, manifest.Entry{ Path: "~/replaced-file", Type: "file", Hash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Mode: "0644", }) if err := g.ReplaceManifest(newManifest); err != nil { t.Fatalf("ReplaceManifest: %v", err) } // Verify in-memory manifest was updated. m := g.GetManifest() if len(m.Files) != 1 { t.Fatalf("expected 1 entry, got %d", len(m.Files)) } if m.Files[0].Path != "~/replaced-file" { t.Errorf("expected path ~/replaced-file, got %s", m.Files[0].Path) } // Verify persistence by re-opening. g2, err := Open(repoDir) if err != nil { t.Fatalf("re-Open: %v", err) } m2 := g2.GetManifest() if len(m2.Files) != 1 { t.Fatalf("persisted manifest has %d entries, want 1", len(m2.Files)) } if m2.Files[0].Path != "~/replaced-file" { t.Errorf("persisted entry path = %s, want ~/replaced-file", m2.Files[0].Path) } } 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) } } }