Step 6: Restore with timestamp logic and confirm callback.

Restore writes tracked files back to their original locations.
Supports selective path restoration, force mode, and a confirm
callback for files where the on-disk mtime >= manifest timestamp
(truncated to seconds for cross-platform reliability). Creates
parent directories, recreates symlinks, and sets file permissions.

CLI: sgard restore [path...] [--force].
6 new tests (file, permissions, symlink, parent dirs, selective, confirm skip).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-23 21:41:53 -07:00
parent 661c050d83
commit c552a3657f
5 changed files with 407 additions and 5 deletions

View File

@@ -7,7 +7,7 @@ ARCHITECTURE.md for design details.
## Current Status ## Current Status
**Phase:** Steps 15 complete. Ready for Step 6 (Restore). **Phase:** Steps 16 complete. Ready for Step 7 (Remaining Commands).
**Last updated:** 2026-03-23 **Last updated:** 2026-03-23
@@ -27,6 +27,9 @@ ARCHITECTURE.md for design details.
- **Step 5: Checkpoint and Status** — `Checkpoint()` re-hashes all tracked files, - **Step 5: Checkpoint and Status** — `Checkpoint()` re-hashes all tracked files,
stores changed blobs, updates timestamps. `Status()` reports ok/modified/missing stores changed blobs, updates timestamps. `Status()` reports ok/modified/missing
per entry. CLI `checkpoint` (with `-m` flag) and `status` commands. 4 tests. per entry. CLI `checkpoint` (with `-m` flag) and `status` commands. 4 tests.
- **Step 6: Restore** — `Restore()` with selective paths, force mode, confirm
callback, timestamp-based auto-restore, parent dir creation, symlink support,
file permission restoration. CLI `restore` with `--force` flag. 6 tests.
## In Progress ## In Progress
@@ -34,7 +37,7 @@ ARCHITECTURE.md for design details.
## Up Next ## Up Next
Step 6: Restore. Step 7: Remaining Commands (remove, verify, list, diff).
## Known Issues / Decisions Deferred ## Known Issues / Decisions Deferred
@@ -53,3 +56,4 @@ Step 6: Restore.
| 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. | | 2026-03-23 | 4 | Garden core complete. Init, Open, Add with file/dir/symlink support, CLI commands. 8 tests. |
| 2026-03-23 | 5 | Checkpoint and Status complete. Re-hash, store changed blobs, status reporting. 4 tests. | | 2026-03-23 | 5 | Checkpoint and Status complete. Re-hash, store changed blobs, status reporting. 4 tests. |
| 2026-03-23 | 6 | Restore complete. Selective paths, force/confirm, timestamp logic, symlinks, permissions. 6 tests. |

View File

@@ -63,15 +63,15 @@ Depends on Step 4.
Depends on Step 5. Depends on Step 5.
- [ ] `garden/garden.go`: `Restore(paths []string, force bool) error` - [x] `garden/garden.go`: `Restore(paths []string, force bool, confirm func) error`
- Restore all files if paths is empty, otherwise just the specified paths - Restore all files if paths is empty, otherwise just the specified paths
- Timestamp comparison: skip prompt if manifest `updated` is newer than file mtime - Timestamp comparison: skip prompt if manifest `updated` is newer than file mtime
- Prompt user if file on disk is newer or times match (unless `--force`) - Prompt user if file on disk is newer or times match (unless `--force`)
- Create parent directories as needed - Create parent directories as needed
- Recreate symlinks for `link` type entries - Recreate symlinks for `link` type entries
- Set file permissions from manifest `mode` - Set file permissions from manifest `mode`
- [ ] `garden/garden_test.go`: restore writes correct content, respects permissions, handles symlinks - [x] `garden/garden_test.go`: restore writes correct content, respects permissions, handles symlinks
- [ ] Wire up CLI: `cmd/sgard/restore.go` - [x] Wire up CLI: `cmd/sgard/restore.go`
## Step 7: Remaining Commands ## Step 7: Remaining Commands

46
cmd/sgard/restore.go Normal file
View File

@@ -0,0 +1,46 @@
package main
import (
"bufio"
"fmt"
"os"
"strings"
"github.com/kisom/sgard/garden"
"github.com/spf13/cobra"
)
var forceRestore bool
var restoreCmd = &cobra.Command{
Use: "restore [path...]",
Short: "Restore tracked files to their original locations",
RunE: func(cmd *cobra.Command, args []string) error {
g, err := garden.Open(repoFlag)
if err != nil {
return err
}
confirm := func(path string) bool {
fmt.Printf("Overwrite %s? [y/N] ", path)
scanner := bufio.NewScanner(os.Stdin)
if scanner.Scan() {
answer := strings.TrimSpace(strings.ToLower(scanner.Text()))
return answer == "y" || answer == "yes"
}
return false
}
if err := g.Restore(args, forceRestore, confirm); err != nil {
return err
}
fmt.Println("Restore complete.")
return nil
},
}
func init() {
restoreCmd.Flags().BoolVarP(&forceRestore, "force", "f", false, "overwrite without prompting")
rootCmd.AddCommand(restoreCmd)
}

View File

@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"time" "time"
@@ -269,6 +270,137 @@ func (g *Garden) Status() ([]FileStatus, error) {
return results, nil return results, nil
} }
// Restore writes tracked files back to their original locations. If paths is
// empty, all entries are restored. If force is true, existing files are
// overwritten without prompting. Otherwise, confirm is called for files where
// the on-disk version is newer than or equal to the manifest timestamp;
// if confirm returns false, that file is skipped.
func (g *Garden) Restore(paths []string, force bool, confirm func(path string) bool) error {
entries := g.manifest.Files
if len(paths) > 0 {
entries = g.filterEntries(paths)
if len(entries) == 0 {
return fmt.Errorf("no matching tracked entries")
}
}
for i := range entries {
entry := &entries[i]
abs, err := ExpandTildePath(entry.Path)
if err != nil {
return fmt.Errorf("expanding path %s: %w", entry.Path, err)
}
// Check if the file exists and whether we need confirmation.
if !force {
if info, err := os.Lstat(abs); err == nil {
// File exists. If on-disk mtime >= manifest updated, ask.
// Truncate to seconds because filesystem mtime granularity
// varies across platforms.
diskTime := info.ModTime().Truncate(time.Second)
entryTime := entry.Updated.Truncate(time.Second)
if !diskTime.Before(entryTime) {
if confirm == nil || !confirm(entry.Path) {
continue
}
}
}
}
// Create parent directories.
dir := filepath.Dir(abs)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("creating directory %s: %w", dir, err)
}
switch entry.Type {
case "file":
if err := g.restoreFile(abs, entry); err != nil {
return err
}
case "link":
if err := restoreLink(abs, entry); err != nil {
return err
}
case "directory":
mode, err := parseMode(entry.Mode)
if err != nil {
return fmt.Errorf("parsing mode for %s: %w", entry.Path, err)
}
if err := os.MkdirAll(abs, mode); err != nil {
return fmt.Errorf("creating directory %s: %w", abs, err)
}
}
}
return nil
}
func (g *Garden) restoreFile(abs string, entry *manifest.Entry) error {
data, err := g.store.Read(entry.Hash)
if err != nil {
return fmt.Errorf("reading blob for %s: %w", entry.Path, err)
}
mode, err := parseMode(entry.Mode)
if err != nil {
return fmt.Errorf("parsing mode for %s: %w", entry.Path, err)
}
if err := os.WriteFile(abs, data, mode); err != nil {
return fmt.Errorf("writing %s: %w", abs, err)
}
return nil
}
func restoreLink(abs string, entry *manifest.Entry) error {
// Remove existing file/link at the target path so we can create the symlink.
os.Remove(abs)
if err := os.Symlink(entry.Target, abs); err != nil {
return fmt.Errorf("creating symlink %s -> %s: %w", abs, entry.Target, err)
}
return nil
}
// filterEntries returns manifest entries whose tilde paths match any of the
// given paths (resolved to tilde form).
func (g *Garden) filterEntries(paths []string) []manifest.Entry {
wanted := make(map[string]bool)
for _, p := range paths {
abs, err := filepath.Abs(p)
if err != nil {
wanted[p] = true
continue
}
wanted[toTildePath(abs)] = true
}
var result []manifest.Entry
for _, e := range g.manifest.Files {
if wanted[e.Path] {
result = append(result, e)
}
}
return result
}
// parseMode converts a mode string like "0644" to an os.FileMode.
func parseMode(s string) (os.FileMode, error) {
if s == "" {
return 0o644, nil
}
v, err := strconv.ParseUint(s, 8, 32)
if err != nil {
return 0, fmt.Errorf("invalid mode %q: %w", s, err)
}
return os.FileMode(v), nil
}
// findEntry returns the entry for the given tilde path, or nil if not found. // findEntry returns the entry for the given tilde path, or nil if not found.
func (g *Garden) findEntry(tildePath string) *manifest.Entry { func (g *Garden) findEntry(tildePath string) *manifest.Entry {
for i := range g.manifest.Files { for i := range g.manifest.Files {

View File

@@ -391,6 +391,226 @@ func TestStatusReportsCorrectly(t *testing.T) {
} }
} }
func TestRestoreFile(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 and add it.
testFile := filepath.Join(root, "testfile")
content := []byte("restore me\n")
if err := os.WriteFile(testFile, content, 0o644); err != nil {
t.Fatalf("writing test file: %v", err)
}
if err := g.Add([]string{testFile}); err != nil {
t.Fatalf("Add: %v", err)
}
// Delete the file, then restore it.
os.Remove(testFile)
if err := g.Restore(nil, true, nil); err != nil {
t.Fatalf("Restore: %v", err)
}
got, err := os.ReadFile(testFile)
if err != nil {
t.Fatalf("reading restored file: %v", err)
}
if string(got) != string(content) {
t.Errorf("restored content = %q, want %q", got, content)
}
}
func TestRestorePermissions(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, "secret")
if err := os.WriteFile(testFile, []byte("secret"), 0o600); err != nil {
t.Fatalf("writing test file: %v", err)
}
if err := g.Add([]string{testFile}); err != nil {
t.Fatalf("Add: %v", err)
}
os.Remove(testFile)
if err := g.Restore(nil, true, nil); err != nil {
t.Fatalf("Restore: %v", err)
}
info, err := os.Stat(testFile)
if err != nil {
t.Fatalf("stat restored file: %v", err)
}
if info.Mode().Perm() != 0o600 {
t.Errorf("permissions = %04o, want 0600", info.Mode().Perm())
}
}
func TestRestoreSymlink(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
target := filepath.Join(root, "target")
if err := os.WriteFile(target, []byte("target"), 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)
}
os.Remove(link)
if err := g.Restore(nil, true, nil); err != nil {
t.Fatalf("Restore: %v", err)
}
got, err := os.Readlink(link)
if err != nil {
t.Fatalf("readlink: %v", err)
}
if got != target {
t.Errorf("symlink target = %q, want %q", got, target)
}
}
func TestRestoreCreatesParentDirs(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
// Create nested file.
nested := filepath.Join(root, "a", "b", "c")
if err := os.MkdirAll(filepath.Dir(nested), 0o755); err != nil {
t.Fatalf("creating dirs: %v", err)
}
if err := os.WriteFile(nested, []byte("deep"), 0o644); err != nil {
t.Fatalf("writing nested file: %v", err)
}
if err := g.Add([]string{nested}); err != nil {
t.Fatalf("Add: %v", err)
}
// Remove the entire directory tree.
os.RemoveAll(filepath.Join(root, "a"))
if err := g.Restore(nil, true, nil); err != nil {
t.Fatalf("Restore: %v", err)
}
got, err := os.ReadFile(nested)
if err != nil {
t.Fatalf("reading restored nested file: %v", err)
}
if string(got) != "deep" {
t.Errorf("content = %q, want %q", got, "deep")
}
}
func TestRestoreSelectivePaths(t *testing.T) {
root := t.TempDir()
repoDir := filepath.Join(root, "repo")
g, err := Init(repoDir)
if err != nil {
t.Fatalf("Init: %v", err)
}
file1 := filepath.Join(root, "file1")
file2 := filepath.Join(root, "file2")
if err := os.WriteFile(file1, []byte("one"), 0o644); err != nil {
t.Fatalf("writing file1: %v", err)
}
if err := os.WriteFile(file2, []byte("two"), 0o644); err != nil {
t.Fatalf("writing file2: %v", err)
}
if err := g.Add([]string{file1, file2}); err != nil {
t.Fatalf("Add: %v", err)
}
os.Remove(file1)
os.Remove(file2)
// Restore only file1.
if err := g.Restore([]string{file1}, true, nil); err != nil {
t.Fatalf("Restore: %v", err)
}
if _, err := os.Stat(file1); err != nil {
t.Error("file1 should have been restored")
}
if _, err := os.Stat(file2); err == nil {
t.Error("file2 should NOT have been restored")
}
}
func TestRestoreConfirmSkips(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("original"), 0o644); err != nil {
t.Fatalf("writing test file: %v", err)
}
if err := g.Add([]string{testFile}); err != nil {
t.Fatalf("Add: %v", err)
}
// Overwrite with newer content (file mtime will be >= manifest updated).
if err := os.WriteFile(testFile, []byte("newer on disk"), 0o644); err != nil {
t.Fatalf("modifying test file: %v", err)
}
// Confirm returns false — should skip the file.
alwaysNo := func(path string) bool { return false }
if err := g.Restore(nil, false, alwaysNo); err != nil {
t.Fatalf("Restore: %v", err)
}
got, err := os.ReadFile(testFile)
if err != nil {
t.Fatalf("reading file: %v", err)
}
if string(got) != "newer on disk" {
t.Error("file should not have been overwritten when confirm returns false")
}
}
func TestExpandTildePath(t *testing.T) { func TestExpandTildePath(t *testing.T) {
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
if err != nil { if err != nil {