Enables glob workflows like `sgard add ~/.config/mcp/services/*` to pick up new files without failing on ones already tracked. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
673 lines
17 KiB
Go
673 lines
17 KiB
Go
// Package garden is the core business logic for sgard. It orchestrates the
|
|
// manifest and blob store to implement dotfile management operations.
|
|
package garden
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jonboulle/clockwork"
|
|
"github.com/kisom/sgard/manifest"
|
|
"github.com/kisom/sgard/store"
|
|
)
|
|
|
|
// Garden ties a manifest and blob store together to manage dotfiles.
|
|
type Garden struct {
|
|
manifest *manifest.Manifest
|
|
store *store.Store
|
|
root string // repository root directory
|
|
manifestPath string // path to manifest.yaml
|
|
clock clockwork.Clock
|
|
dek []byte // unlocked data encryption key (nil if not unlocked)
|
|
}
|
|
|
|
// Init creates a new sgard repository at root. It creates the directory
|
|
// structure and an empty manifest.
|
|
func Init(root string) (*Garden, error) {
|
|
absRoot, err := filepath.Abs(root)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolving repo path: %w", err)
|
|
}
|
|
|
|
manifestPath := filepath.Join(absRoot, "manifest.yaml")
|
|
if _, err := os.Stat(manifestPath); err == nil {
|
|
return nil, fmt.Errorf("repository already exists at %s", absRoot)
|
|
}
|
|
|
|
if err := os.MkdirAll(absRoot, 0o755); err != nil {
|
|
return nil, fmt.Errorf("creating repo directory: %w", err)
|
|
}
|
|
|
|
s, err := store.New(absRoot)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating store: %w", err)
|
|
}
|
|
|
|
gitignorePath := filepath.Join(absRoot, ".gitignore")
|
|
if err := os.WriteFile(gitignorePath, []byte("blobs/\ntags\n"), 0o644); err != nil {
|
|
return nil, fmt.Errorf("creating .gitignore: %w", err)
|
|
}
|
|
|
|
clk := clockwork.NewRealClock()
|
|
m := manifest.NewWithTime(clk.Now().UTC())
|
|
if err := m.Save(manifestPath); err != nil {
|
|
return nil, fmt.Errorf("saving initial manifest: %w", err)
|
|
}
|
|
|
|
return &Garden{
|
|
manifest: m,
|
|
store: s,
|
|
root: absRoot,
|
|
manifestPath: manifestPath,
|
|
clock: clk,
|
|
}, nil
|
|
}
|
|
|
|
// Open loads an existing sgard repository from root.
|
|
func Open(root string) (*Garden, error) {
|
|
absRoot, err := filepath.Abs(root)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolving repo path: %w", err)
|
|
}
|
|
|
|
manifestPath := filepath.Join(absRoot, "manifest.yaml")
|
|
m, err := manifest.Load(manifestPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("loading manifest: %w", err)
|
|
}
|
|
|
|
s, err := store.New(absRoot)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("opening store: %w", err)
|
|
}
|
|
|
|
return &Garden{
|
|
manifest: m,
|
|
store: s,
|
|
root: absRoot,
|
|
manifestPath: manifestPath,
|
|
clock: clockwork.NewRealClock(),
|
|
}, nil
|
|
}
|
|
|
|
// SetClock replaces the clock used for timestamps. Intended for testing.
|
|
func (g *Garden) SetClock(c clockwork.Clock) {
|
|
g.clock = c
|
|
}
|
|
|
|
// GetManifest returns the current manifest.
|
|
func (g *Garden) GetManifest() *manifest.Manifest {
|
|
return g.manifest
|
|
}
|
|
|
|
// BlobExists reports whether a blob with the given hash exists in the store.
|
|
func (g *Garden) BlobExists(hash string) bool {
|
|
return g.store.Exists(hash)
|
|
}
|
|
|
|
// ReadBlob returns the contents of the blob with the given hash.
|
|
func (g *Garden) ReadBlob(hash string) ([]byte, error) {
|
|
return g.store.Read(hash)
|
|
}
|
|
|
|
// WriteBlob writes data to the blob store and returns the hash.
|
|
func (g *Garden) WriteBlob(data []byte) (string, error) {
|
|
return g.store.Write(data)
|
|
}
|
|
|
|
// ReplaceManifest atomically replaces the current manifest.
|
|
func (g *Garden) ReplaceManifest(m *manifest.Manifest) error {
|
|
if err := m.Save(g.manifestPath); err != nil {
|
|
return fmt.Errorf("saving manifest: %w", err)
|
|
}
|
|
g.manifest = m
|
|
return nil
|
|
}
|
|
|
|
// ListBlobs returns all blob hashes in the store.
|
|
func (g *Garden) ListBlobs() ([]string, error) {
|
|
return g.store.List()
|
|
}
|
|
|
|
// DeleteBlob removes a blob from the store by hash.
|
|
func (g *Garden) DeleteBlob(hash string) error {
|
|
return g.store.Delete(hash)
|
|
}
|
|
|
|
// addEntry adds a single file or symlink to the manifest. If skipDup is true,
|
|
// already-tracked paths are silently skipped.
|
|
func (g *Garden) addEntry(abs string, info os.FileInfo, now time.Time, skipDup bool, o AddOptions) 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()),
|
|
Locked: o.Lock,
|
|
Only: o.Only,
|
|
Never: o.Never,
|
|
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)
|
|
}
|
|
|
|
if o.Encrypt {
|
|
if g.dek == nil {
|
|
return fmt.Errorf("DEK not unlocked; cannot encrypt %s", abs)
|
|
}
|
|
entry.PlaintextHash = plaintextHash(data)
|
|
ct, err := g.encryptBlob(data)
|
|
if err != nil {
|
|
return fmt.Errorf("encrypting %s: %w", abs, err)
|
|
}
|
|
data = ct
|
|
entry.Encrypted = true
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// AddOptions controls the behavior of Add.
|
|
type AddOptions struct {
|
|
Encrypt bool // encrypt file blobs before storing
|
|
Lock bool // mark entries as locked (repo-authoritative)
|
|
DirOnly bool // for directories: track the directory itself, don't recurse
|
|
Only []string // per-machine targeting: only apply on matching machines
|
|
Never []string // per-machine targeting: never apply on matching machines
|
|
}
|
|
|
|
// 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. Directories are
|
|
// recursively walked unless opts.DirOnly is set.
|
|
func (g *Garden) Add(paths []string, opts ...AddOptions) error {
|
|
var o AddOptions
|
|
if len(opts) > 0 {
|
|
o = opts[0]
|
|
}
|
|
if o.Encrypt && g.dek == nil {
|
|
return fmt.Errorf("DEK not unlocked; run sgard encrypt init or unlock first")
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
info, err := os.Lstat(abs)
|
|
if err != nil {
|
|
return fmt.Errorf("stat %s: %w", abs, err)
|
|
}
|
|
|
|
if info.IsDir() {
|
|
if o.DirOnly {
|
|
// Track the directory itself as a structural entry.
|
|
tilded := toTildePath(abs)
|
|
if g.findEntry(tilded) != nil {
|
|
continue
|
|
}
|
|
entry := manifest.Entry{
|
|
Path: tilded,
|
|
Type: "directory",
|
|
Mode: fmt.Sprintf("%04o", info.Mode().Perm()),
|
|
Locked: o.Lock,
|
|
Only: o.Only,
|
|
Never: o.Never,
|
|
Updated: now,
|
|
}
|
|
g.manifest.Files = append(g.manifest.Files, entry)
|
|
} else {
|
|
err := filepath.WalkDir(abs, func(path string, d os.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tilded := toTildePath(path)
|
|
if g.manifest.IsExcluded(tilded) {
|
|
if d.IsDir() {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
if d.IsDir() {
|
|
return nil
|
|
}
|
|
fi, err := os.Lstat(path)
|
|
if err != nil {
|
|
return fmt.Errorf("stat %s: %w", path, err)
|
|
}
|
|
return g.addEntry(path, fi, now, true, o)
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("walking directory %s: %w", abs, err)
|
|
}
|
|
}
|
|
} else {
|
|
if err := g.addEntry(abs, info, now, true, o); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
g.manifest.Updated = now
|
|
if err := g.manifest.Save(g.manifestPath); err != nil {
|
|
return fmt.Errorf("saving manifest: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// FileStatus reports the state of a tracked entry relative to the filesystem.
|
|
type FileStatus struct {
|
|
Path string // tilde path from manifest
|
|
State string // "ok", "modified", "missing"
|
|
}
|
|
|
|
// Checkpoint re-hashes all tracked files, stores any changed blobs, and
|
|
// updates the manifest timestamps. The optional message is recorded in
|
|
// the manifest.
|
|
func (g *Garden) Checkpoint(message string) error {
|
|
now := g.clock.Now().UTC()
|
|
labels := g.Identity()
|
|
|
|
for i := range g.manifest.Files {
|
|
entry := &g.manifest.Files[i]
|
|
|
|
applies, err := EntryApplies(entry, labels)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !applies {
|
|
continue
|
|
}
|
|
|
|
abs, err := ExpandTildePath(entry.Path)
|
|
if err != nil {
|
|
return fmt.Errorf("expanding path %s: %w", entry.Path, err)
|
|
}
|
|
|
|
info, err := os.Lstat(abs)
|
|
if err != nil {
|
|
// File is missing — leave the manifest entry as-is so status
|
|
// can report it. Don't fail the whole checkpoint.
|
|
continue
|
|
}
|
|
|
|
entry.Mode = fmt.Sprintf("%04o", info.Mode().Perm())
|
|
|
|
// Locked entries are repo-authoritative — checkpoint skips them.
|
|
if entry.Locked {
|
|
continue
|
|
}
|
|
|
|
switch entry.Type {
|
|
case "file":
|
|
data, err := os.ReadFile(abs)
|
|
if err != nil {
|
|
return fmt.Errorf("reading %s: %w", abs, err)
|
|
}
|
|
|
|
if entry.Encrypted {
|
|
// For encrypted entries, check plaintext hash to detect changes.
|
|
ptHash := plaintextHash(data)
|
|
if ptHash != entry.PlaintextHash {
|
|
if g.dek == nil {
|
|
return fmt.Errorf("DEK not unlocked; cannot re-encrypt %s", abs)
|
|
}
|
|
ct, err := g.encryptBlob(data)
|
|
if err != nil {
|
|
return fmt.Errorf("encrypting %s: %w", abs, err)
|
|
}
|
|
hash, err := g.store.Write(ct)
|
|
if err != nil {
|
|
return fmt.Errorf("storing blob for %s: %w", abs, err)
|
|
}
|
|
entry.Hash = hash
|
|
entry.PlaintextHash = ptHash
|
|
entry.Updated = now
|
|
}
|
|
} else {
|
|
hash, err := g.store.Write(data)
|
|
if err != nil {
|
|
return fmt.Errorf("storing blob for %s: %w", abs, err)
|
|
}
|
|
if hash != entry.Hash {
|
|
entry.Hash = hash
|
|
entry.Updated = now
|
|
}
|
|
}
|
|
|
|
case "link":
|
|
target, err := os.Readlink(abs)
|
|
if err != nil {
|
|
return fmt.Errorf("reading symlink %s: %w", abs, err)
|
|
}
|
|
if target != entry.Target {
|
|
entry.Target = target
|
|
entry.Updated = now
|
|
}
|
|
|
|
case "directory":
|
|
// Nothing to hash; just update mode (already done above).
|
|
}
|
|
}
|
|
|
|
g.manifest.Updated = now
|
|
g.manifest.Message = message
|
|
if err := g.manifest.Save(g.manifestPath); err != nil {
|
|
return fmt.Errorf("saving manifest: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Status compares each tracked entry against the current filesystem state
|
|
// and returns a status for each.
|
|
func (g *Garden) Status() ([]FileStatus, error) {
|
|
var results []FileStatus
|
|
labels := g.Identity()
|
|
|
|
for i := range g.manifest.Files {
|
|
entry := &g.manifest.Files[i]
|
|
|
|
applies, err := EntryApplies(entry, labels)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !applies {
|
|
results = append(results, FileStatus{Path: entry.Path, State: "skipped"})
|
|
continue
|
|
}
|
|
|
|
abs, err := ExpandTildePath(entry.Path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("expanding path %s: %w", entry.Path, err)
|
|
}
|
|
|
|
_, err = os.Lstat(abs)
|
|
if os.IsNotExist(err) {
|
|
results = append(results, FileStatus{Path: entry.Path, State: "missing"})
|
|
continue
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stat %s: %w", abs, err)
|
|
}
|
|
|
|
switch entry.Type {
|
|
case "file":
|
|
hash, err := HashFile(abs)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("hashing %s: %w", abs, err)
|
|
}
|
|
// For encrypted entries, compare against plaintext hash.
|
|
compareHash := entry.Hash
|
|
if entry.Encrypted && entry.PlaintextHash != "" {
|
|
compareHash = entry.PlaintextHash
|
|
}
|
|
if hash != compareHash {
|
|
if entry.Locked {
|
|
results = append(results, FileStatus{Path: entry.Path, State: "drifted"})
|
|
} else {
|
|
results = append(results, FileStatus{Path: entry.Path, State: "modified"})
|
|
}
|
|
} else {
|
|
results = append(results, FileStatus{Path: entry.Path, State: "ok"})
|
|
}
|
|
|
|
case "link":
|
|
target, err := os.Readlink(abs)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading symlink %s: %w", abs, err)
|
|
}
|
|
if target != entry.Target {
|
|
results = append(results, FileStatus{Path: entry.Path, State: "modified"})
|
|
} else {
|
|
results = append(results, FileStatus{Path: entry.Path, State: "ok"})
|
|
}
|
|
|
|
case "directory":
|
|
results = append(results, FileStatus{Path: entry.Path, State: "ok"})
|
|
}
|
|
}
|
|
|
|
return results, nil
|
|
}
|
|
|
|
// Restore writes tracked files back to their original locations. If paths is
|
|
// empty, all entries are restored. If force is true, existing files are
|
|
// overwritten without prompting. Otherwise, confirm is called for files where
|
|
// the on-disk version is newer than or equal to the manifest timestamp;
|
|
// if confirm returns false, that file is skipped.
|
|
func (g *Garden) Restore(paths []string, force bool, confirm func(path string) bool) error {
|
|
entries := g.manifest.Files
|
|
if len(paths) > 0 {
|
|
entries = g.filterEntries(paths)
|
|
if len(entries) == 0 {
|
|
return fmt.Errorf("no matching tracked entries")
|
|
}
|
|
}
|
|
|
|
labels := g.Identity()
|
|
|
|
for i := range entries {
|
|
entry := &entries[i]
|
|
|
|
applies, err := EntryApplies(entry, labels)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !applies {
|
|
continue
|
|
}
|
|
|
|
abs, err := ExpandTildePath(entry.Path)
|
|
if err != nil {
|
|
return fmt.Errorf("expanding path %s: %w", entry.Path, err)
|
|
}
|
|
|
|
// Locked entries always restore if content differs — no prompt.
|
|
if entry.Locked && entry.Type == "file" {
|
|
if currentHash, err := HashFile(abs); err == nil {
|
|
compareHash := entry.Hash
|
|
if entry.Encrypted && entry.PlaintextHash != "" {
|
|
compareHash = entry.PlaintextHash
|
|
}
|
|
if currentHash == compareHash {
|
|
continue // already matches, skip
|
|
}
|
|
}
|
|
// File is missing or hash differs — proceed to restore.
|
|
} else if !force {
|
|
// Normal entries: check timestamp for confirmation.
|
|
if info, err := os.Lstat(abs); err == nil {
|
|
diskTime := info.ModTime().Truncate(time.Second)
|
|
entryTime := entry.Updated.Truncate(time.Second)
|
|
if !diskTime.Before(entryTime) {
|
|
if confirm == nil || !confirm(entry.Path) {
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create parent directories.
|
|
dir := filepath.Dir(abs)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return fmt.Errorf("creating directory %s: %w", dir, err)
|
|
}
|
|
|
|
switch entry.Type {
|
|
case "file":
|
|
if err := g.restoreFile(abs, entry); err != nil {
|
|
return err
|
|
}
|
|
|
|
case "link":
|
|
if err := restoreLink(abs, entry); err != nil {
|
|
return err
|
|
}
|
|
|
|
case "directory":
|
|
mode, err := parseMode(entry.Mode)
|
|
if err != nil {
|
|
return fmt.Errorf("parsing mode for %s: %w", entry.Path, err)
|
|
}
|
|
if err := os.MkdirAll(abs, mode); err != nil {
|
|
return fmt.Errorf("creating directory %s: %w", abs, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (g *Garden) restoreFile(abs string, entry *manifest.Entry) error {
|
|
data, err := g.store.Read(entry.Hash)
|
|
if err != nil {
|
|
return fmt.Errorf("reading blob for %s: %w", entry.Path, err)
|
|
}
|
|
|
|
if entry.Encrypted {
|
|
if g.dek == nil {
|
|
return fmt.Errorf("DEK not unlocked; cannot decrypt %s", entry.Path)
|
|
}
|
|
data, err = g.decryptBlob(data)
|
|
if err != nil {
|
|
return fmt.Errorf("decrypting %s: %w", entry.Path, err)
|
|
}
|
|
}
|
|
|
|
mode, err := parseMode(entry.Mode)
|
|
if err != nil {
|
|
return fmt.Errorf("parsing mode for %s: %w", entry.Path, err)
|
|
}
|
|
|
|
if err := os.WriteFile(abs, data, mode); err != nil {
|
|
return fmt.Errorf("writing %s: %w", abs, err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func restoreLink(abs string, entry *manifest.Entry) error {
|
|
// Remove existing file/link at the target path so we can create the symlink.
|
|
_ = os.Remove(abs)
|
|
|
|
if err := os.Symlink(entry.Target, abs); err != nil {
|
|
return fmt.Errorf("creating symlink %s -> %s: %w", abs, entry.Target, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// filterEntries returns manifest entries whose tilde paths match any of the
|
|
// given paths (resolved to tilde form).
|
|
func (g *Garden) filterEntries(paths []string) []manifest.Entry {
|
|
wanted := make(map[string]bool)
|
|
for _, p := range paths {
|
|
abs, err := filepath.Abs(p)
|
|
if err != nil {
|
|
wanted[p] = true
|
|
continue
|
|
}
|
|
wanted[toTildePath(abs)] = true
|
|
}
|
|
|
|
var result []manifest.Entry
|
|
for _, e := range g.manifest.Files {
|
|
if wanted[e.Path] {
|
|
result = append(result, e)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// parseMode converts a mode string like "0644" to an os.FileMode.
|
|
func parseMode(s string) (os.FileMode, error) {
|
|
if s == "" {
|
|
return 0o644, nil
|
|
}
|
|
v, err := strconv.ParseUint(s, 8, 32)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("invalid mode %q: %w", s, err)
|
|
}
|
|
return os.FileMode(v), nil
|
|
}
|
|
|
|
// findEntry returns the entry for the given tilde path, or nil if not found.
|
|
func (g *Garden) findEntry(tildePath string) *manifest.Entry {
|
|
for i := range g.manifest.Files {
|
|
if g.manifest.Files[i].Path == tildePath {
|
|
return &g.manifest.Files[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// toTildePath converts an absolute path to a ~/... path if it falls under
|
|
// the user's home directory. Otherwise returns the path unchanged.
|
|
func toTildePath(abs string) string {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return abs
|
|
}
|
|
// Ensure trailing separator for prefix matching.
|
|
homePrefix := home + string(filepath.Separator)
|
|
if abs == home {
|
|
return "~"
|
|
}
|
|
if strings.HasPrefix(abs, homePrefix) {
|
|
return "~/" + abs[len(homePrefix):]
|
|
}
|
|
return abs
|
|
}
|
|
|
|
// ExpandTildePath converts a ~/... path to an absolute path using the
|
|
// current user's home directory. Non-tilde paths are returned unchanged.
|
|
func ExpandTildePath(p string) (string, error) {
|
|
if p == "~" || strings.HasPrefix(p, "~/") {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", fmt.Errorf("expanding ~: %w", err)
|
|
}
|
|
if p == "~" {
|
|
return home, nil
|
|
}
|
|
return filepath.Join(home, p[2:]), nil
|
|
}
|
|
return p, nil
|
|
}
|