Merge branch 'worktree-agent-a0166844'
# Conflicts: # garden/garden.go
This commit is contained in:
72
cmd/sgard/mirror.go
Normal file
72
cmd/sgard/mirror.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/kisom/sgard/garden"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var forceMirror bool
|
||||||
|
|
||||||
|
var mirrorCmd = &cobra.Command{
|
||||||
|
Use: "mirror",
|
||||||
|
Short: "Sync directory contents between filesystem and manifest",
|
||||||
|
}
|
||||||
|
|
||||||
|
var mirrorUpCmd = &cobra.Command{
|
||||||
|
Use: "up <path>...",
|
||||||
|
Short: "Sync filesystem state into manifest (add new, remove deleted, rehash changed)",
|
||||||
|
Args: cobra.MinimumNArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
g, err := garden.Open(repoFlag)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := g.MirrorUp(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Mirror up complete.")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var mirrorDownCmd = &cobra.Command{
|
||||||
|
Use: "down <path>...",
|
||||||
|
Short: "Sync manifest state to filesystem (restore tracked, delete untracked)",
|
||||||
|
Args: cobra.MinimumNArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
g, err := garden.Open(repoFlag)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
confirm := func(path string) bool {
|
||||||
|
fmt.Printf("Delete untracked file %s? [y/N] ", path)
|
||||||
|
scanner := bufio.NewScanner(os.Stdin)
|
||||||
|
if scanner.Scan() {
|
||||||
|
answer := strings.TrimSpace(strings.ToLower(scanner.Text()))
|
||||||
|
return answer == "y" || answer == "yes"
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := g.MirrorDown(args, forceMirror, confirm); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Mirror down complete.")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
mirrorDownCmd.Flags().BoolVarP(&forceMirror, "force", "f", false, "delete untracked files without prompting")
|
||||||
|
mirrorCmd.AddCommand(mirrorUpCmd, mirrorDownCmd)
|
||||||
|
rootCmd.AddCommand(mirrorCmd)
|
||||||
|
}
|
||||||
102
garden/garden.go
102
garden/garden.go
@@ -127,9 +127,55 @@ func (g *Garden) ReplaceManifest(m *manifest.Manifest) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// addEntry adds a single file or symlink to the manifest. The abs path must
|
||||||
|
// already be resolved and info must come from os.Lstat. If skipDup is true,
|
||||||
|
// already-tracked paths are silently skipped instead of returning an error.
|
||||||
|
func (g *Garden) addEntry(abs string, info os.FileInfo, now time.Time, skipDup bool) error {
|
||||||
|
tilded := toTildePath(abs)
|
||||||
|
|
||||||
|
if g.findEntry(tilded) != nil {
|
||||||
|
if skipDup {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("already tracking %s", tilded)
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := manifest.Entry{
|
||||||
|
Path: tilded,
|
||||||
|
Mode: fmt.Sprintf("%04o", info.Mode().Perm()),
|
||||||
|
Updated: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case info.Mode()&os.ModeSymlink != 0:
|
||||||
|
target, err := os.Readlink(abs)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading symlink %s: %w", abs, err)
|
||||||
|
}
|
||||||
|
entry.Type = "link"
|
||||||
|
entry.Target = target
|
||||||
|
|
||||||
|
default:
|
||||||
|
data, err := os.ReadFile(abs)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading file %s: %w", abs, err)
|
||||||
|
}
|
||||||
|
hash, err := g.store.Write(data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("storing blob for %s: %w", abs, err)
|
||||||
|
}
|
||||||
|
entry.Type = "file"
|
||||||
|
entry.Hash = hash
|
||||||
|
}
|
||||||
|
|
||||||
|
g.manifest.Files = append(g.manifest.Files, entry)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Add tracks new files, directories, or symlinks. Each path is resolved
|
// Add tracks new files, directories, or symlinks. Each path is resolved
|
||||||
// to an absolute path, inspected for its type, and added to the manifest.
|
// to an absolute path, inspected for its type, and added to the manifest.
|
||||||
// Regular files are hashed and stored in the blob store.
|
// Regular files are hashed and stored in the blob store. Directories are
|
||||||
|
// recursively walked and all leaf files and symlinks are added individually.
|
||||||
func (g *Garden) Add(paths []string) error {
|
func (g *Garden) Add(paths []string) error {
|
||||||
now := g.clock.Now().UTC()
|
now := g.clock.Now().UTC()
|
||||||
|
|
||||||
@@ -144,45 +190,29 @@ func (g *Garden) Add(paths []string) error {
|
|||||||
return fmt.Errorf("stat %s: %w", abs, err)
|
return fmt.Errorf("stat %s: %w", abs, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
tilded := toTildePath(abs)
|
if info.IsDir() {
|
||||||
|
// Recursively walk the directory, adding all files and symlinks.
|
||||||
// Check if already tracked.
|
err := filepath.WalkDir(abs, func(path string, d os.DirEntry, err error) error {
|
||||||
if g.findEntry(tilded) != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("already tracking %s", tilded)
|
return err
|
||||||
}
|
}
|
||||||
|
if d.IsDir() {
|
||||||
entry := manifest.Entry{
|
return nil // skip directory entries themselves
|
||||||
Path: tilded,
|
}
|
||||||
Mode: fmt.Sprintf("%04o", info.Mode().Perm()),
|
fi, err := os.Lstat(path)
|
||||||
Updated: now,
|
if err != nil {
|
||||||
}
|
return fmt.Errorf("stat %s: %w", path, err)
|
||||||
|
}
|
||||||
switch {
|
return g.addEntry(path, fi, now, true)
|
||||||
case info.Mode()&os.ModeSymlink != 0:
|
})
|
||||||
target, err := os.Readlink(abs)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("reading symlink %s: %w", abs, err)
|
return fmt.Errorf("walking directory %s: %w", abs, err)
|
||||||
}
|
}
|
||||||
entry.Type = "link"
|
} else {
|
||||||
entry.Target = target
|
if err := g.addEntry(abs, info, now, false); err != nil {
|
||||||
|
return err
|
||||||
case info.IsDir():
|
|
||||||
entry.Type = "directory"
|
|
||||||
|
|
||||||
default:
|
|
||||||
data, err := os.ReadFile(abs)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("reading file %s: %w", abs, err)
|
|
||||||
}
|
}
|
||||||
hash, err := g.store.Write(data)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("storing blob for %s: %w", abs, err)
|
|
||||||
}
|
|
||||||
entry.Type = "file"
|
|
||||||
entry.Hash = hash
|
|
||||||
}
|
}
|
||||||
|
|
||||||
g.manifest.Files = append(g.manifest.Files, entry)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
g.manifest.Updated = now
|
g.manifest.Updated = now
|
||||||
|
|||||||
@@ -139,17 +139,29 @@ func TestAddDirectory(t *testing.T) {
|
|||||||
if err := os.Mkdir(testDir, 0o755); err != nil {
|
if err := os.Mkdir(testDir, 0o755); err != nil {
|
||||||
t.Fatalf("creating test dir: %v", err)
|
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 {
|
if err := g.Add([]string{testDir}); err != nil {
|
||||||
t.Fatalf("Add: %v", err)
|
t.Fatalf("Add: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
entry := g.manifest.Files[0]
|
if len(g.manifest.Files) != 1 {
|
||||||
if entry.Type != "directory" {
|
t.Fatalf("expected 1 file, got %d", len(g.manifest.Files))
|
||||||
t.Errorf("expected type directory, got %s", entry.Type)
|
|
||||||
}
|
}
|
||||||
if entry.Hash != "" {
|
|
||||||
t.Error("directories should have no hash")
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
187
garden/mirror.go
Normal file
187
garden/mirror.go
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
package garden
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MirrorUp synchronises the manifest with the current filesystem state for
|
||||||
|
// each given directory path. New files/symlinks are added, deleted files are
|
||||||
|
// removed from the manifest, and changed files are re-hashed.
|
||||||
|
func (g *Garden) MirrorUp(paths []string) error {
|
||||||
|
now := g.clock.Now().UTC()
|
||||||
|
|
||||||
|
for _, p := range paths {
|
||||||
|
abs, err := filepath.Abs(p)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolving path %s: %w", p, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tildePrefix := toTildePath(abs)
|
||||||
|
// Ensure we match entries *under* the directory, not just the dir itself.
|
||||||
|
if !strings.HasSuffix(tildePrefix, "/") {
|
||||||
|
tildePrefix += "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Walk the directory and add any new files/symlinks.
|
||||||
|
err = filepath.WalkDir(abs, func(path string, d os.DirEntry, walkErr error) error {
|
||||||
|
if walkErr != nil {
|
||||||
|
return walkErr
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fi, lstatErr := os.Lstat(path)
|
||||||
|
if lstatErr != nil {
|
||||||
|
return fmt.Errorf("stat %s: %w", path, lstatErr)
|
||||||
|
}
|
||||||
|
return g.addEntry(path, fi, now, true)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("walking directory %s: %w", abs, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Remove manifest entries whose files no longer exist on disk.
|
||||||
|
kept := g.manifest.Files[:0]
|
||||||
|
for _, e := range g.manifest.Files {
|
||||||
|
if strings.HasPrefix(e.Path, tildePrefix) {
|
||||||
|
expanded, err := ExpandTildePath(e.Path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("expanding path %s: %w", e.Path, err)
|
||||||
|
}
|
||||||
|
if _, err := os.Lstat(expanded); err != nil {
|
||||||
|
// File no longer exists — drop entry.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
kept = append(kept, e)
|
||||||
|
}
|
||||||
|
g.manifest.Files = kept
|
||||||
|
|
||||||
|
// 3. Re-hash remaining file entries under the prefix (like Checkpoint).
|
||||||
|
for i := range g.manifest.Files {
|
||||||
|
entry := &g.manifest.Files[i]
|
||||||
|
if !strings.HasPrefix(entry.Path, tildePrefix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if entry.Type != "file" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
expanded, err := ExpandTildePath(entry.Path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("expanding path %s: %w", entry.Path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(expanded)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading %s: %w", expanded, err)
|
||||||
|
}
|
||||||
|
hash, err := g.store.Write(data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("storing blob for %s: %w", expanded, err)
|
||||||
|
}
|
||||||
|
if hash != entry.Hash {
|
||||||
|
entry.Hash = hash
|
||||||
|
entry.Updated = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
g.manifest.Updated = now
|
||||||
|
if err := g.manifest.Save(g.manifestPath); err != nil {
|
||||||
|
return fmt.Errorf("saving manifest: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MirrorDown synchronises the filesystem with the manifest for each given
|
||||||
|
// directory path. Tracked entries are restored and untracked files on disk
|
||||||
|
// are deleted. If force is false, confirm is called before each deletion;
|
||||||
|
// a false return skips that file.
|
||||||
|
func (g *Garden) MirrorDown(paths []string, force bool, confirm func(string) bool) error {
|
||||||
|
for _, p := range paths {
|
||||||
|
abs, err := filepath.Abs(p)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolving path %s: %w", p, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tildePrefix := toTildePath(abs)
|
||||||
|
if !strings.HasSuffix(tildePrefix, "/") {
|
||||||
|
tildePrefix += "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Collect manifest entries under this prefix.
|
||||||
|
tracked := make(map[string]bool)
|
||||||
|
for i := range g.manifest.Files {
|
||||||
|
entry := &g.manifest.Files[i]
|
||||||
|
if !strings.HasPrefix(entry.Path, tildePrefix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
expanded, err := ExpandTildePath(entry.Path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("expanding path %s: %w", entry.Path, err)
|
||||||
|
}
|
||||||
|
tracked[expanded] = true
|
||||||
|
|
||||||
|
// Create parent directories.
|
||||||
|
dir := filepath.Dir(expanded)
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("creating directory %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore the entry.
|
||||||
|
switch entry.Type {
|
||||||
|
case "file":
|
||||||
|
if err := g.restoreFile(expanded, entry); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case "link":
|
||||||
|
if err := restoreLink(expanded, entry); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Walk disk and delete files not in manifest.
|
||||||
|
var emptyDirs []string
|
||||||
|
err = filepath.WalkDir(abs, func(path string, d os.DirEntry, walkErr error) error {
|
||||||
|
if walkErr != nil {
|
||||||
|
return walkErr
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
// Collect directories for potential cleanup (post-order).
|
||||||
|
if path != abs {
|
||||||
|
emptyDirs = append(emptyDirs, path)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if tracked[path] {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Untracked file/symlink on disk.
|
||||||
|
if !force {
|
||||||
|
if confirm == nil || !confirm(path) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = os.Remove(path)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("walking directory %s: %w", abs, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Clean up empty directories (reverse order so children come first).
|
||||||
|
for i := len(emptyDirs) - 1; i >= 0; i-- {
|
||||||
|
// os.Remove only removes empty directories.
|
||||||
|
_ = os.Remove(emptyDirs[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
297
garden/mirror_test.go
Normal file
297
garden/mirror_test.go
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user