Files
sgard/garden/mirror.go
Kyle Isom de5759ac77 Add file exclusion support (sgard exclude/include).
Paths added to the manifest's exclude list are skipped during Add,
MirrorUp, and MirrorDown directory walks. Excluding a directory
excludes everything under it. Already-tracked entries matching a
new exclusion are removed from the manifest.

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

202 lines
5.1 KiB
Go

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
}
tilded := toTildePath(path)
if g.manifest.IsExcluded(tilded) {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
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, AddOptions{})
})
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() {
if g.manifest.IsExcluded(toTildePath(path)) {
return filepath.SkipDir
}
// Collect directories for potential cleanup (post-order).
if path != abs {
emptyDirs = append(emptyDirs, path)
}
return nil
}
if tracked[path] {
return nil
}
// Excluded paths are left alone on disk.
if g.manifest.IsExcluded(toTildePath(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
}