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:
2026-03-23 21:34:55 -07:00
parent 87db4b912f
commit 1550bdf940
7 changed files with 529 additions and 10 deletions

193
garden/garden.go Normal file
View 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
}

247
garden/garden_test.go Normal file
View File

@@ -0,0 +1,247 @@
package garden
import (
"os"
"path/filepath"
"testing"
)
func TestInitCreatesStructure(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
// manifest.yaml should exist
if _, err := os.Stat(filepath.Join(repoDir, "manifest.yaml")); err != nil {
t.Errorf("manifest.yaml not found: %v", err)
}
// blobs/ directory should exist
if _, err := os.Stat(filepath.Join(repoDir, "blobs")); err != nil {
t.Errorf("blobs/ not found: %v", err)
}
if g.manifest.Version != 1 {
t.Errorf("expected version 1, got %d", g.manifest.Version)
}
if len(g.manifest.Files) != 0 {
t.Errorf("expected 0 files, got %d", len(g.manifest.Files))
}
}
func TestInitRejectsExisting(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
if _, err := Init(repoDir); err != nil {
t.Fatalf("first Init: %v", err)
}
if _, err := Init(repoDir); err == nil {
t.Fatal("second Init should fail on existing repo")
}
}
func TestOpenLoadsRepo(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
if _, err := Init(repoDir); err != nil {
t.Fatalf("Init: %v", err)
}
g, err := Open(repoDir)
if err != nil {
t.Fatalf("Open: %v", err)
}
if g.manifest.Version != 1 {
t.Errorf("expected version 1, got %d", g.manifest.Version)
}
}
func TestAddFile(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 file to add.
testFile := filepath.Join(root, "testfile")
if err := os.WriteFile(testFile, []byte("hello world\n"), 0o644); err != nil {
t.Fatalf("writing test file: %v", err)
}
if err := g.Add([]string{testFile}); err != nil {
t.Fatalf("Add: %v", err)
}
if len(g.manifest.Files) != 1 {
t.Fatalf("expected 1 file, got %d", len(g.manifest.Files))
}
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")
}
if entry.Mode != "0644" {
t.Errorf("expected mode 0644, got %s", entry.Mode)
}
// Verify the blob was stored.
if !g.store.Exists(entry.Hash) {
t.Error("blob not found in store")
}
// Verify manifest was persisted.
g2, err := Open(repoDir)
if err != nil {
t.Fatalf("re-Open: %v", err)
}
if len(g2.manifest.Files) != 1 {
t.Errorf("persisted manifest has %d files, want 1", len(g2.manifest.Files))
}
}
func TestAddDirectory(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
testDir := filepath.Join(root, "testdir")
if err := os.Mkdir(testDir, 0o755); err != nil {
t.Fatalf("creating test 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 entry.Hash != "" {
t.Error("directories should have no hash")
}
}
func TestAddSymlink(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 target and a symlink to it.
target := filepath.Join(root, "target")
if err := os.WriteFile(target, []byte("target content"), 0o644); err != nil {
t.Fatalf("writing target: %v", err)
}
link := filepath.Join(root, "link")
if err := os.Symlink(target, link); err != nil {
t.Fatalf("creating symlink: %v", err)
}
if err := g.Add([]string{link}); err != nil {
t.Fatalf("Add: %v", err)
}
entry := g.manifest.Files[0]
if entry.Type != "link" {
t.Errorf("expected type link, got %s", entry.Type)
}
if entry.Target != target {
t.Errorf("expected target %s, got %s", target, entry.Target)
}
if entry.Hash != "" {
t.Error("symlinks should have no hash")
}
}
func TestAddDuplicateRejected(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
testFile := filepath.Join(root, "testfile")
if err := os.WriteFile(testFile, []byte("data"), 0o644); err != nil {
t.Fatalf("writing test file: %v", err)
}
if err := g.Add([]string{testFile}); err != nil {
t.Fatalf("first Add: %v", err)
}
if err := g.Add([]string{testFile}); err == nil {
t.Fatal("second Add of same path should fail")
}
}
func TestHashFile(t *testing.T) {
root := t.TempDir()
testFile := filepath.Join(root, "testfile")
if err := os.WriteFile(testFile, []byte("hello"), 0o644); err != nil {
t.Fatalf("writing test file: %v", err)
}
hash, err := HashFile(testFile)
if err != nil {
t.Fatalf("HashFile: %v", err)
}
// SHA-256 of "hello"
expected := "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
if hash != expected {
t.Errorf("expected %s, got %s", expected, hash)
}
}
func TestExpandTildePath(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Skipf("cannot get home dir: %v", err)
}
tests := []struct {
input string
want string
}{
{"~", home},
{"~/foo", filepath.Join(home, "foo")},
{"~/.config/nvim", filepath.Join(home, ".config/nvim")},
{"/tmp/foo", "/tmp/foo"},
}
for _, tt := range tests {
got, err := ExpandTildePath(tt.input)
if err != nil {
t.Errorf("ExpandTildePath(%q): %v", tt.input, err)
continue
}
if got != tt.want {
t.Errorf("ExpandTildePath(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}

19
garden/hasher.go Normal file
View File

@@ -0,0 +1,19 @@
package garden
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
)
// HashFile computes the SHA-256 hash of the file at path and returns
// the hex-encoded hash string.
func HashFile(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("hashing file: %w", err)
}
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:]), nil
}