Step 4: Garden core with Init, Open, Add and CLI commands.
Garden package ties manifest and store together. Supports adding files (hashed and stored as blobs), directories (manifest-only), and symlinks (target recorded). Paths under $HOME are stored as ~/... in the manifest for portability. CLI init and add commands wired up via cobra. 8 tests covering init, open, add for all three entry types, duplicate rejection, HashFile, and tilde path expansion. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
193
garden/garden.go
Normal file
193
garden/garden.go
Normal file
@@ -0,0 +1,193 @@
|
||||
// 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"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
m := manifest.New()
|
||||
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,
|
||||
}, 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,
|
||||
}, 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.
|
||||
func (g *Garden) Add(paths []string) error {
|
||||
now := time.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)
|
||||
}
|
||||
|
||||
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 err != nil {
|
||||
return fmt.Errorf("reading symlink %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)
|
||||
}
|
||||
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
|
||||
if err := g.manifest.Save(g.manifestPath); err != nil {
|
||||
return fmt.Errorf("saving manifest: %w", err)
|
||||
}
|
||||
|
||||
return 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
|
||||
}
|
||||
Reference in New Issue
Block a user