From 1550bdf9403295b12ef31fbcbb63ff1d645080d2 Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Mon, 23 Mar 2026 21:34:55 -0700 Subject: [PATCH] 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) --- PROGRESS.md | 8 +- PROJECT_PLAN.md | 16 +-- cmd/sgard/add.go | 31 ++++++ cmd/sgard/init.go | 25 +++++ garden/garden.go | 193 +++++++++++++++++++++++++++++++++ garden/garden_test.go | 247 ++++++++++++++++++++++++++++++++++++++++++ garden/hasher.go | 19 ++++ 7 files changed, 529 insertions(+), 10 deletions(-) create mode 100644 cmd/sgard/add.go create mode 100644 cmd/sgard/init.go create mode 100644 garden/garden.go create mode 100644 garden/garden_test.go create mode 100644 garden/hasher.go diff --git a/PROGRESS.md b/PROGRESS.md index dc1b1ec..f3b9ae3 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -7,7 +7,7 @@ ARCHITECTURE.md for design details. ## Current Status -**Phase:** Steps 2 & 3 complete. Ready for Step 4 (Garden Core). +**Phase:** Steps 1–4 complete. Ready for Step 5 (Checkpoint and Status). **Last updated:** 2026-03-23 @@ -21,6 +21,9 @@ ARCHITECTURE.md for design details. - **Step 3: Store Package** — content-addressable blob store with SHA-256 keying. `New()`, `Write()`, `Read()`, `Exists()`, `Delete()` with atomic writes, hash validation, and two-level directory layout. 11 tests. +- **Step 4: Garden Core — Init and Add** — `Garden` struct tying manifest + + store, `Init()`, `Open()`, `Add()` handling files/dirs/symlinks, `HashFile()`, + tilde path conversion, CLI `init` and `add` commands. 8 tests. ## In Progress @@ -28,7 +31,7 @@ ARCHITECTURE.md for design details. ## Up Next -Step 4: Garden Core — Init and Add. Ties manifest + store together. +Step 5: Checkpoint and Status. ## Known Issues / Decisions Deferred @@ -45,3 +48,4 @@ Step 4: Garden Core — Init and Add. Ties manifest + store together. | 2026-03-23 | 1 | Scaffolding complete. Old C++ removed, Go module initialized, cobra root command. | | 2026-03-23 | 2 | Manifest package complete. Structs, Load/Save with atomic write, full test suite. | | 2026-03-23 | 3 | Store package complete. Content-addressable blob store, 11 tests. | +| 2026-03-23 | 4 | Garden core complete. Init, Open, Add with file/dir/symlink support, CLI commands. 8 tests. | diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index d7364ac..c3ac5f2 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -41,14 +41,14 @@ Remove old C++ source files and set up the Go project. Depends on Steps 2 and 3. -- [ ] `garden/hasher.go`: `HashFile(path) (string, error)` — SHA-256 of a file -- [ ] `garden/garden.go`: `Garden` struct tying manifest + store + root path -- [ ] `garden/garden.go`: `Open(root) (*Garden, error)` — load existing repo -- [ ] `garden/garden.go`: `Init(root) (*Garden, error)` — create new repo (dirs + empty manifest) -- [ ] `garden/garden.go`: `Add(paths []string) error` — hash files, store blobs, add manifest entries -- [ ] `garden/garden_test.go`: init creates correct structure, add stores blob and updates manifest -- [ ] Wire up CLI: `cmd/sgard/init.go`, `cmd/sgard/add.go` -- [ ] Verify: `go build ./cmd/sgard && ./sgard init && ./sgard add ~/.bashrc` +- [x] `garden/hasher.go`: `HashFile(path) (string, error)` — SHA-256 of a file +- [x] `garden/garden.go`: `Garden` struct tying manifest + store + root path +- [x] `garden/garden.go`: `Open(root) (*Garden, error)` — load existing repo +- [x] `garden/garden.go`: `Init(root) (*Garden, error)` — create new repo (dirs + empty manifest) +- [x] `garden/garden.go`: `Add(paths []string) error` — hash files, store blobs, add manifest entries +- [x] `garden/garden_test.go`: init creates correct structure, add stores blob and updates manifest +- [x] Wire up CLI: `cmd/sgard/init.go`, `cmd/sgard/add.go` +- [x] Verify: `go build ./cmd/sgard && ./sgard init && ./sgard add ~/.bashrc` ## Step 5: Checkpoint and Status diff --git a/cmd/sgard/add.go b/cmd/sgard/add.go new file mode 100644 index 0000000..708841d --- /dev/null +++ b/cmd/sgard/add.go @@ -0,0 +1,31 @@ +package main + +import ( + "fmt" + + "github.com/kisom/sgard/garden" + "github.com/spf13/cobra" +) + +var addCmd = &cobra.Command{ + Use: "add ...", + Short: "Track files, directories, or symlinks", + 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.Add(args); err != nil { + return err + } + + fmt.Printf("Added %d path(s)\n", len(args)) + return nil + }, +} + +func init() { + rootCmd.AddCommand(addCmd) +} diff --git a/cmd/sgard/init.go b/cmd/sgard/init.go new file mode 100644 index 0000000..94c4c7b --- /dev/null +++ b/cmd/sgard/init.go @@ -0,0 +1,25 @@ +package main + +import ( + "fmt" + + "github.com/kisom/sgard/garden" + "github.com/spf13/cobra" +) + +var initCmd = &cobra.Command{ + Use: "init", + Short: "Create a new sgard repository", + RunE: func(cmd *cobra.Command, args []string) error { + _, err := garden.Init(repoFlag) + if err != nil { + return err + } + fmt.Printf("Initialized sgard repository at %s\n", repoFlag) + return nil + }, +} + +func init() { + rootCmd.AddCommand(initCmd) +} diff --git a/garden/garden.go b/garden/garden.go new file mode 100644 index 0000000..45a26f0 --- /dev/null +++ b/garden/garden.go @@ -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 +} diff --git a/garden/garden_test.go b/garden/garden_test.go new file mode 100644 index 0000000..15ca30f --- /dev/null +++ b/garden/garden_test.go @@ -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) + } + } +} diff --git a/garden/hasher.go b/garden/hasher.go new file mode 100644 index 0000000..fd8e3df --- /dev/null +++ b/garden/hasher.go @@ -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 +}