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
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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 {
|
||||
now := g.clock.Now().UTC()
|
||||
|
||||
@@ -144,45 +190,29 @@ func (g *Garden) Add(paths []string) error {
|
||||
return fmt.Errorf("stat %s: %w", abs, err)
|
||||
}
|
||||
|
||||
tilded := toTildePath(abs)
|
||||
|
||||
// Check if already tracked.
|
||||
if g.findEntry(tilded) != 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 info.IsDir() {
|
||||
// Recursively walk the directory, adding all files and symlinks.
|
||||
err := filepath.WalkDir(abs, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil // skip directory entries themselves
|
||||
}
|
||||
fi, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stat %s: %w", path, err)
|
||||
}
|
||||
return g.addEntry(path, fi, now, true)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading symlink %s: %w", abs, err)
|
||||
return fmt.Errorf("walking directory %s: %w", abs, err)
|
||||
}
|
||||
entry.Type = "link"
|
||||
entry.Target = target
|
||||
|
||||
case info.IsDir():
|
||||
entry.Type = "directory"
|
||||
|
||||
default:
|
||||
data, err := os.ReadFile(abs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading file %s: %w", abs, err)
|
||||
} else {
|
||||
if err := g.addEntry(abs, info, now, false); err != nil {
|
||||
return 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
|
||||
|
||||
@@ -139,17 +139,29 @@ func TestAddDirectory(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
|
||||
entry := g.manifest.Files[0]
|
||||
if entry.Type != "directory" {
|
||||
t.Errorf("expected type directory, got %s", entry.Type)
|
||||
if len(g.manifest.Files) != 1 {
|
||||
t.Fatalf("expected 1 file, got %d", len(g.manifest.Files))
|
||||
}
|
||||
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