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:
@@ -7,7 +7,7 @@ ARCHITECTURE.md for design details.
|
|||||||
|
|
||||||
## Current Status
|
## 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
|
**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.
|
- **Step 3: Store Package** — content-addressable blob store with SHA-256 keying.
|
||||||
`New()`, `Write()`, `Read()`, `Exists()`, `Delete()` with atomic writes,
|
`New()`, `Write()`, `Read()`, `Exists()`, `Delete()` with atomic writes,
|
||||||
hash validation, and two-level directory layout. 11 tests.
|
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
|
## In Progress
|
||||||
|
|
||||||
@@ -28,7 +31,7 @@ ARCHITECTURE.md for design details.
|
|||||||
|
|
||||||
## Up Next
|
## Up Next
|
||||||
|
|
||||||
Step 4: Garden Core — Init and Add. Ties manifest + store together.
|
Step 5: Checkpoint and Status.
|
||||||
|
|
||||||
## Known Issues / Decisions Deferred
|
## 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 | 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 | 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 | 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. |
|
||||||
|
|||||||
@@ -41,14 +41,14 @@ Remove old C++ source files and set up the Go project.
|
|||||||
|
|
||||||
Depends on Steps 2 and 3.
|
Depends on Steps 2 and 3.
|
||||||
|
|
||||||
- [ ] `garden/hasher.go`: `HashFile(path) (string, error)` — SHA-256 of a file
|
- [x] `garden/hasher.go`: `HashFile(path) (string, error)` — SHA-256 of a file
|
||||||
- [ ] `garden/garden.go`: `Garden` struct tying manifest + store + root path
|
- [x] `garden/garden.go`: `Garden` struct tying manifest + store + root path
|
||||||
- [ ] `garden/garden.go`: `Open(root) (*Garden, error)` — load existing repo
|
- [x] `garden/garden.go`: `Open(root) (*Garden, error)` — load existing repo
|
||||||
- [ ] `garden/garden.go`: `Init(root) (*Garden, error)` — create new repo (dirs + empty manifest)
|
- [x] `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
|
- [x] `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
|
- [x] `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`
|
- [x] Wire up CLI: `cmd/sgard/init.go`, `cmd/sgard/add.go`
|
||||||
- [ ] Verify: `go build ./cmd/sgard && ./sgard init && ./sgard add ~/.bashrc`
|
- [x] Verify: `go build ./cmd/sgard && ./sgard init && ./sgard add ~/.bashrc`
|
||||||
|
|
||||||
## Step 5: Checkpoint and Status
|
## Step 5: Checkpoint and Status
|
||||||
|
|
||||||
|
|||||||
31
cmd/sgard/add.go
Normal file
31
cmd/sgard/add.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/kisom/sgard/garden"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var addCmd = &cobra.Command{
|
||||||
|
Use: "add <path>...",
|
||||||
|
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)
|
||||||
|
}
|
||||||
25
cmd/sgard/init.go
Normal file
25
cmd/sgard/init.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
247
garden/garden_test.go
Normal file
247
garden/garden_test.go
Normal 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
19
garden/hasher.go
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user