Step 2: Add manifest package with YAML data model and persistence.

Implements Manifest and Entry structs with YAML tags, New() constructor,
Load(path) for reading, and Save(path) with atomic write (temp file +
rename). Tests cover round-trip serialization, atomic save cleanup,
entry type invariants, nonexistent file error, and empty manifest.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-23 21:24:56 -07:00
parent 6cadda01a8
commit 7a3d78d741
4 changed files with 328 additions and 6 deletions

View File

@@ -7,7 +7,7 @@ ARCHITECTURE.md for design details.
## Current Status
**Phase:** Step 1 complete. Ready for Steps 2 & 3 (can be parallel).
**Phase:** Step 2 complete. Ready for Step 3 (Store Package) and then Step 4.
**Last updated:** 2026-03-23
@@ -16,6 +16,9 @@ ARCHITECTURE.md for design details.
- **Step 1: Project Scaffolding** — removed old C++ files and `.trunk/` config,
initialized Go module, added cobra + yaml.v3 deps, created package dirs,
set up cobra root command with `--repo` flag.
- **Step 2: Manifest Package** — `Manifest` and `Entry` structs with YAML tags,
`New()`, `Load(path)`, and `Save(path)` with atomic write. Tests cover
round-trip, atomic save, entry types, nonexistent file, and empty manifest.
## In Progress
@@ -23,8 +26,7 @@ ARCHITECTURE.md for design details.
## Up Next
Step 2 (Manifest Package) and Step 3 (Store Package) these can be done
in parallel.
Step 3 (Store Package), then Step 4 (Garden Core).
## Known Issues / Decisions Deferred
@@ -39,3 +41,4 @@ in parallel.
|---|---|---|
| 2026-03-23 | — | Design phase complete. ARCHITECTURE.md and PROJECT_PLAN.md written. |
| 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. |

View File

@@ -18,13 +18,13 @@ Remove old C++ source files and set up the Go project.
*Can be done in parallel with Step 3.*
- [ ] `manifest/manifest.go`: `Manifest` and `Entry` structs with YAML tags
- [x] `manifest/manifest.go`: `Manifest` and `Entry` structs with YAML tags
- Entry types: `file`, `directory`, `link`
- Mode as string type to avoid YAML octal coercion
- Per-file `updated` timestamp
- [ ] `manifest/manifest.go`: `Load(path)` and `Save(path)` functions
- [x] `manifest/manifest.go`: `Load(path)` and `Save(path)` functions
- Save uses atomic write (write to `.tmp`, rename)
- [ ] `manifest/manifest_test.go`: round-trip marshal/unmarshal, atomic save, entry type validation
- [x] `manifest/manifest_test.go`: round-trip marshal/unmarshal, atomic save, entry type validation
## Step 3: Store Package

91
manifest/manifest.go Normal file
View File

@@ -0,0 +1,91 @@
package manifest
import (
"fmt"
"os"
"path/filepath"
"time"
"gopkg.in/yaml.v3"
)
// Entry represents a single tracked file, directory, or symlink.
type Entry struct {
Path string `yaml:"path"`
Hash string `yaml:"hash,omitempty"`
Type string `yaml:"type"`
Mode string `yaml:"mode,omitempty"`
Target string `yaml:"target,omitempty"`
Updated time.Time `yaml:"updated"`
}
// Manifest is the top-level manifest describing all tracked entries.
type Manifest struct {
Version int `yaml:"version"`
Created time.Time `yaml:"created"`
Updated time.Time `yaml:"updated"`
Message string `yaml:"message,omitempty"`
Files []Entry `yaml:"files"`
}
// New creates a new empty manifest with Version 1 and timestamps set to now.
func New() *Manifest {
now := time.Now().UTC()
return &Manifest{
Version: 1,
Created: now,
Updated: now,
Files: []Entry{},
}
}
// Load reads a manifest from the given file path.
func Load(path string) (*Manifest, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading manifest: %w", err)
}
var m Manifest
if err := yaml.Unmarshal(data, &m); err != nil {
return nil, fmt.Errorf("parsing manifest: %w", err)
}
return &m, nil
}
// Save writes the manifest to the given file path using an atomic
// write: data is marshalled to a temporary file in the same directory,
// then renamed to the target path. This prevents corruption if the
// process crashes mid-write.
func (m *Manifest) Save(path string) error {
data, err := yaml.Marshal(m)
if err != nil {
return fmt.Errorf("marshalling manifest: %w", err)
}
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, filepath.Base(path)+".tmp.*")
if err != nil {
return fmt.Errorf("creating temp file: %w", err)
}
tmpName := tmp.Name()
if _, err := tmp.Write(data); err != nil {
tmp.Close()
os.Remove(tmpName)
return fmt.Errorf("writing temp file: %w", err)
}
if err := tmp.Close(); err != nil {
os.Remove(tmpName)
return fmt.Errorf("closing temp file: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
os.Remove(tmpName)
return fmt.Errorf("renaming temp file: %w", err)
}
return nil
}

228
manifest/manifest_test.go Normal file
View File

@@ -0,0 +1,228 @@
package manifest
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestRoundTrip(t *testing.T) {
now := time.Now().UTC().Truncate(time.Second)
m := &Manifest{
Version: 1,
Created: now,
Updated: now,
Message: "test checkpoint",
Files: []Entry{
{
Path: "~/.bashrc",
Hash: "a1b2c3d4e5f6",
Type: "file",
Mode: "0644",
Updated: now,
},
{
Path: "~/.config/nvim",
Type: "directory",
Mode: "0755",
Updated: now,
},
{
Path: "~/.vimrc",
Type: "link",
Target: "~/.config/nvim/init.vim",
Updated: now,
},
},
}
dir := t.TempDir()
path := filepath.Join(dir, "manifest.yaml")
if err := m.Save(path); err != nil {
t.Fatalf("Save: %v", err)
}
loaded, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if loaded.Version != m.Version {
t.Errorf("Version: got %d, want %d", loaded.Version, m.Version)
}
if !loaded.Created.Equal(m.Created) {
t.Errorf("Created: got %v, want %v", loaded.Created, m.Created)
}
if !loaded.Updated.Equal(m.Updated) {
t.Errorf("Updated: got %v, want %v", loaded.Updated, m.Updated)
}
if loaded.Message != m.Message {
t.Errorf("Message: got %q, want %q", loaded.Message, m.Message)
}
if len(loaded.Files) != len(m.Files) {
t.Fatalf("Files: got %d entries, want %d", len(loaded.Files), len(m.Files))
}
for i, got := range loaded.Files {
want := m.Files[i]
if got.Path != want.Path {
t.Errorf("Files[%d].Path: got %q, want %q", i, got.Path, want.Path)
}
if got.Hash != want.Hash {
t.Errorf("Files[%d].Hash: got %q, want %q", i, got.Hash, want.Hash)
}
if got.Type != want.Type {
t.Errorf("Files[%d].Type: got %q, want %q", i, got.Type, want.Type)
}
if got.Mode != want.Mode {
t.Errorf("Files[%d].Mode: got %q, want %q", i, got.Mode, want.Mode)
}
if got.Target != want.Target {
t.Errorf("Files[%d].Target: got %q, want %q", i, got.Target, want.Target)
}
if !got.Updated.Equal(want.Updated) {
t.Errorf("Files[%d].Updated: got %v, want %v", i, got.Updated, want.Updated)
}
}
}
func TestAtomicSave(t *testing.T) {
m := New()
dir := t.TempDir()
path := filepath.Join(dir, "manifest.yaml")
if err := m.Save(path); err != nil {
t.Fatalf("Save: %v", err)
}
// Target file must exist.
if _, err := os.Stat(path); err != nil {
t.Errorf("target file missing: %v", err)
}
// No .tmp files should remain.
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("ReadDir: %v", err)
}
for _, e := range entries {
if strings.Contains(e.Name(), ".tmp") {
t.Errorf("temp file remains: %s", e.Name())
}
}
// Verify content is valid YAML that loads back.
loaded, err := Load(path)
if err != nil {
t.Fatalf("Load after save: %v", err)
}
if loaded.Version != 1 {
t.Errorf("loaded version: got %d, want 1", loaded.Version)
}
}
func TestEntryTypes(t *testing.T) {
now := time.Now().UTC().Truncate(time.Second)
m := &Manifest{
Version: 1,
Created: now,
Updated: now,
Files: []Entry{
{
Path: "~/.bashrc",
Hash: "abc123",
Type: "file",
Mode: "0644",
Updated: now,
},
{
Path: "~/.config",
Type: "directory",
Mode: "0755",
Updated: now,
},
{
Path: "~/.vimrc",
Type: "link",
Target: "~/.config/nvim/init.vim",
Updated: now,
},
},
}
dir := t.TempDir()
path := filepath.Join(dir, "manifest.yaml")
if err := m.Save(path); err != nil {
t.Fatalf("Save: %v", err)
}
loaded, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
// File entry: has hash and mode.
file := loaded.Files[0]
if file.Hash == "" {
t.Error("file entry should have hash")
}
if file.Mode == "" {
t.Error("file entry should have mode")
}
// Directory entry: has mode, no hash.
directory := loaded.Files[1]
if directory.Hash != "" {
t.Errorf("directory entry should have no hash, got %q", directory.Hash)
}
if directory.Mode == "" {
t.Error("directory entry should have mode")
}
// Link entry: has target, no hash.
link := loaded.Files[2]
if link.Hash != "" {
t.Errorf("link entry should have no hash, got %q", link.Hash)
}
if link.Target == "" {
t.Error("link entry should have target")
}
}
func TestLoadNonexistent(t *testing.T) {
_, err := Load("/nonexistent/path/manifest.yaml")
if err == nil {
t.Fatal("Load of nonexistent file should return error")
}
}
func TestNew(t *testing.T) {
before := time.Now().UTC()
m := New()
after := time.Now().UTC()
if m.Version != 1 {
t.Errorf("Version: got %d, want 1", m.Version)
}
if m.Created.Before(before) || m.Created.After(after) {
t.Errorf("Created %v not between %v and %v", m.Created, before, after)
}
if m.Updated.Before(before) || m.Updated.After(after) {
t.Errorf("Updated %v not between %v and %v", m.Updated, before, after)
}
if m.Message != "" {
t.Errorf("Message: got %q, want empty", m.Message)
}
if m.Files == nil {
t.Error("Files should be non-nil empty slice")
}
if len(m.Files) != 0 {
t.Errorf("Files: got %d entries, want 0", len(m.Files))
}
}