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>
298 lines
7.6 KiB
Go
298 lines
7.6 KiB
Go
package garden
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
func TestAddRecursesDirectory(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 directory tree with nested files.
|
|
dir := filepath.Join(root, "dotfiles")
|
|
if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil {
|
|
t.Fatalf("creating dirs: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "a.conf"), []byte("aaa"), 0o644); err != nil {
|
|
t.Fatalf("writing a.conf: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "sub", "b.conf"), []byte("bbb"), 0o644); err != nil {
|
|
t.Fatalf("writing b.conf: %v", err)
|
|
}
|
|
|
|
if err := g.Add([]string{dir}); err != nil {
|
|
t.Fatalf("Add: %v", err)
|
|
}
|
|
|
|
if len(g.manifest.Files) != 2 {
|
|
t.Fatalf("expected 2 files, got %d", len(g.manifest.Files))
|
|
}
|
|
|
|
for _, e := range g.manifest.Files {
|
|
if e.Type == "directory" {
|
|
t.Errorf("should not have directory type entries, got %+v", e)
|
|
}
|
|
if e.Type != "file" {
|
|
t.Errorf("expected type file, got %s for %s", e.Type, e.Path)
|
|
}
|
|
if e.Hash == "" {
|
|
t.Errorf("expected non-empty hash for %s", e.Path)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAddRecursesSkipsDuplicates(t *testing.T) {
|
|
root := t.TempDir()
|
|
repoDir := filepath.Join(root, "repo")
|
|
|
|
g, err := Init(repoDir)
|
|
if err != nil {
|
|
t.Fatalf("Init: %v", err)
|
|
}
|
|
|
|
dir := filepath.Join(root, "dotfiles")
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("creating dir: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "f.txt"), []byte("data"), 0o644); err != nil {
|
|
t.Fatalf("writing file: %v", err)
|
|
}
|
|
|
|
if err := g.Add([]string{dir}); err != nil {
|
|
t.Fatalf("first Add: %v", err)
|
|
}
|
|
|
|
// Second add of the same directory should not error or create duplicates.
|
|
if err := g.Add([]string{dir}); err != nil {
|
|
t.Fatalf("second Add should not error: %v", err)
|
|
}
|
|
|
|
if len(g.manifest.Files) != 1 {
|
|
t.Errorf("expected 1 entry, got %d", len(g.manifest.Files))
|
|
}
|
|
}
|
|
|
|
func TestMirrorUpAddsNew(t *testing.T) {
|
|
root := t.TempDir()
|
|
repoDir := filepath.Join(root, "repo")
|
|
|
|
g, err := Init(repoDir)
|
|
if err != nil {
|
|
t.Fatalf("Init: %v", err)
|
|
}
|
|
|
|
dir := filepath.Join(root, "dotfiles")
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("creating dir: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "existing.txt"), []byte("old"), 0o644); err != nil {
|
|
t.Fatalf("writing file: %v", err)
|
|
}
|
|
|
|
if err := g.Add([]string{dir}); err != nil {
|
|
t.Fatalf("Add: %v", err)
|
|
}
|
|
|
|
if len(g.manifest.Files) != 1 {
|
|
t.Fatalf("expected 1 file after Add, got %d", len(g.manifest.Files))
|
|
}
|
|
|
|
// Create a new file inside the directory.
|
|
if err := os.WriteFile(filepath.Join(dir, "new.txt"), []byte("new"), 0o644); err != nil {
|
|
t.Fatalf("writing new file: %v", err)
|
|
}
|
|
|
|
if err := g.MirrorUp([]string{dir}); err != nil {
|
|
t.Fatalf("MirrorUp: %v", err)
|
|
}
|
|
|
|
if len(g.manifest.Files) != 2 {
|
|
t.Fatalf("expected 2 files after MirrorUp, got %d", len(g.manifest.Files))
|
|
}
|
|
}
|
|
|
|
func TestMirrorUpRemovesDeleted(t *testing.T) {
|
|
root := t.TempDir()
|
|
repoDir := filepath.Join(root, "repo")
|
|
|
|
g, err := Init(repoDir)
|
|
if err != nil {
|
|
t.Fatalf("Init: %v", err)
|
|
}
|
|
|
|
dir := filepath.Join(root, "dotfiles")
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("creating dir: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "keep.txt"), []byte("keep"), 0o644); err != nil {
|
|
t.Fatalf("writing keep file: %v", err)
|
|
}
|
|
deleteFile := filepath.Join(dir, "delete.txt")
|
|
if err := os.WriteFile(deleteFile, []byte("delete"), 0o644); err != nil {
|
|
t.Fatalf("writing delete file: %v", err)
|
|
}
|
|
|
|
if err := g.Add([]string{dir}); err != nil {
|
|
t.Fatalf("Add: %v", err)
|
|
}
|
|
|
|
if len(g.manifest.Files) != 2 {
|
|
t.Fatalf("expected 2 files, got %d", len(g.manifest.Files))
|
|
}
|
|
|
|
// Delete one file from disk.
|
|
_ = os.Remove(deleteFile)
|
|
|
|
if err := g.MirrorUp([]string{dir}); err != nil {
|
|
t.Fatalf("MirrorUp: %v", err)
|
|
}
|
|
|
|
if len(g.manifest.Files) != 1 {
|
|
t.Fatalf("expected 1 file after MirrorUp, got %d", len(g.manifest.Files))
|
|
}
|
|
|
|
if g.manifest.Files[0].Path != toTildePath(filepath.Join(dir, "keep.txt")) {
|
|
t.Errorf("remaining entry should be keep.txt, got %s", g.manifest.Files[0].Path)
|
|
}
|
|
}
|
|
|
|
func TestMirrorUpRehashesChanged(t *testing.T) {
|
|
root := t.TempDir()
|
|
repoDir := filepath.Join(root, "repo")
|
|
|
|
g, err := Init(repoDir)
|
|
if err != nil {
|
|
t.Fatalf("Init: %v", err)
|
|
}
|
|
|
|
dir := filepath.Join(root, "dotfiles")
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("creating dir: %v", err)
|
|
}
|
|
f := filepath.Join(dir, "config.txt")
|
|
if err := os.WriteFile(f, []byte("original"), 0o644); err != nil {
|
|
t.Fatalf("writing file: %v", err)
|
|
}
|
|
|
|
if err := g.Add([]string{dir}); err != nil {
|
|
t.Fatalf("Add: %v", err)
|
|
}
|
|
|
|
origHash := g.manifest.Files[0].Hash
|
|
|
|
// Modify the file.
|
|
if err := os.WriteFile(f, []byte("modified"), 0o644); err != nil {
|
|
t.Fatalf("modifying file: %v", err)
|
|
}
|
|
|
|
if err := g.MirrorUp([]string{dir}); err != nil {
|
|
t.Fatalf("MirrorUp: %v", err)
|
|
}
|
|
|
|
if g.manifest.Files[0].Hash == origHash {
|
|
t.Error("MirrorUp did not update hash for modified file")
|
|
}
|
|
}
|
|
|
|
func TestMirrorDownRestoresAndCleans(t *testing.T) {
|
|
root := t.TempDir()
|
|
repoDir := filepath.Join(root, "repo")
|
|
|
|
g, err := Init(repoDir)
|
|
if err != nil {
|
|
t.Fatalf("Init: %v", err)
|
|
}
|
|
|
|
dir := filepath.Join(root, "dotfiles")
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("creating dir: %v", err)
|
|
}
|
|
tracked := filepath.Join(dir, "tracked.txt")
|
|
if err := os.WriteFile(tracked, []byte("tracked"), 0o644); err != nil {
|
|
t.Fatalf("writing tracked file: %v", err)
|
|
}
|
|
|
|
if err := g.Add([]string{dir}); err != nil {
|
|
t.Fatalf("Add: %v", err)
|
|
}
|
|
if err := g.Checkpoint(""); err != nil {
|
|
t.Fatalf("Checkpoint: %v", err)
|
|
}
|
|
|
|
// Modify the tracked file and create an untracked file.
|
|
if err := os.WriteFile(tracked, []byte("overwritten"), 0o644); err != nil {
|
|
t.Fatalf("modifying tracked file: %v", err)
|
|
}
|
|
extra := filepath.Join(dir, "extra.txt")
|
|
if err := os.WriteFile(extra, []byte("extra"), 0o644); err != nil {
|
|
t.Fatalf("writing extra file: %v", err)
|
|
}
|
|
|
|
if err := g.MirrorDown([]string{dir}, true, nil); err != nil {
|
|
t.Fatalf("MirrorDown: %v", err)
|
|
}
|
|
|
|
// Tracked file should be restored to original content.
|
|
got, err := os.ReadFile(tracked)
|
|
if err != nil {
|
|
t.Fatalf("reading tracked file: %v", err)
|
|
}
|
|
if string(got) != "tracked" {
|
|
t.Errorf("tracked file content = %q, want %q", got, "tracked")
|
|
}
|
|
|
|
// Extra file should be deleted.
|
|
if _, err := os.Stat(extra); err == nil {
|
|
t.Error("extra file should have been deleted by MirrorDown with force")
|
|
}
|
|
}
|
|
|
|
func TestMirrorDownConfirmSkips(t *testing.T) {
|
|
root := t.TempDir()
|
|
repoDir := filepath.Join(root, "repo")
|
|
|
|
g, err := Init(repoDir)
|
|
if err != nil {
|
|
t.Fatalf("Init: %v", err)
|
|
}
|
|
|
|
dir := filepath.Join(root, "dotfiles")
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("creating dir: %v", err)
|
|
}
|
|
tracked := filepath.Join(dir, "tracked.txt")
|
|
if err := os.WriteFile(tracked, []byte("tracked"), 0o644); err != nil {
|
|
t.Fatalf("writing tracked file: %v", err)
|
|
}
|
|
|
|
if err := g.Add([]string{dir}); err != nil {
|
|
t.Fatalf("Add: %v", err)
|
|
}
|
|
if err := g.Checkpoint(""); err != nil {
|
|
t.Fatalf("Checkpoint: %v", err)
|
|
}
|
|
|
|
// Create an untracked file.
|
|
extra := filepath.Join(dir, "extra.txt")
|
|
if err := os.WriteFile(extra, []byte("extra"), 0o644); err != nil {
|
|
t.Fatalf("writing extra file: %v", err)
|
|
}
|
|
|
|
// Confirm returns false — should NOT delete.
|
|
alwaysNo := func(path string) bool { return false }
|
|
if err := g.MirrorDown([]string{dir}, false, alwaysNo); err != nil {
|
|
t.Fatalf("MirrorDown: %v", err)
|
|
}
|
|
|
|
if _, err := os.Stat(extra); err != nil {
|
|
t.Error("extra file should NOT have been deleted when confirm returns false")
|
|
}
|
|
}
|