Add sgard info command for detailed file inspection.
Shows path, type, status, mode, hash, timestamps, encryption, lock state, and targeting labels for a single tracked file. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
158
garden/info.go
Normal file
158
garden/info.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package garden
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FileInfo holds detailed information about a single tracked entry.
|
||||
type FileInfo struct {
|
||||
Path string // tilde path from manifest
|
||||
Type string // "file", "link", or "directory"
|
||||
State string // "ok", "modified", "drifted", "missing", "skipped"
|
||||
Mode string // octal file mode from manifest
|
||||
Hash string // blob hash from manifest (files only)
|
||||
PlaintextHash string // plaintext hash (encrypted files only)
|
||||
CurrentHash string // SHA-256 of current file on disk (files only, empty if missing)
|
||||
Encrypted bool
|
||||
Locked bool
|
||||
Updated string // manifest timestamp (RFC 3339)
|
||||
DiskModTime string // filesystem modification time (RFC 3339, empty if missing)
|
||||
Target string // symlink target (links only)
|
||||
CurrentTarget string // current symlink target on disk (links only, empty if missing)
|
||||
Only []string // targeting: only these labels
|
||||
Never []string // targeting: never these labels
|
||||
BlobStored bool // whether the blob exists in the store
|
||||
}
|
||||
|
||||
// Info returns detailed information about a tracked file.
|
||||
func (g *Garden) Info(path string) (*FileInfo, error) {
|
||||
abs, err := resolvePath(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tilded := toTildePath(abs)
|
||||
|
||||
entry := g.findEntry(tilded)
|
||||
if entry == nil {
|
||||
// Also try the path as given (it might already be a tilde path).
|
||||
entry = g.findEntry(path)
|
||||
if entry == nil {
|
||||
return nil, fmt.Errorf("not tracked: %s", path)
|
||||
}
|
||||
}
|
||||
|
||||
fi := &FileInfo{
|
||||
Path: entry.Path,
|
||||
Type: entry.Type,
|
||||
Mode: entry.Mode,
|
||||
Hash: entry.Hash,
|
||||
PlaintextHash: entry.PlaintextHash,
|
||||
Encrypted: entry.Encrypted,
|
||||
Locked: entry.Locked,
|
||||
Target: entry.Target,
|
||||
Only: entry.Only,
|
||||
Never: entry.Never,
|
||||
}
|
||||
|
||||
if !entry.Updated.IsZero() {
|
||||
fi.Updated = entry.Updated.Format("2006-01-02 15:04:05 UTC")
|
||||
}
|
||||
|
||||
// Check blob existence for files.
|
||||
if entry.Type == "file" && entry.Hash != "" {
|
||||
fi.BlobStored = g.store.Exists(entry.Hash)
|
||||
}
|
||||
|
||||
// Determine state and filesystem info.
|
||||
labels := g.Identity()
|
||||
applies, err := EntryApplies(entry, labels)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !applies {
|
||||
fi.State = "skipped"
|
||||
return fi, nil
|
||||
}
|
||||
|
||||
entryAbs, err := ExpandTildePath(entry.Path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("expanding path %s: %w", entry.Path, err)
|
||||
}
|
||||
|
||||
info, err := os.Lstat(entryAbs)
|
||||
if os.IsNotExist(err) {
|
||||
fi.State = "missing"
|
||||
return fi, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stat %s: %w", entryAbs, err)
|
||||
}
|
||||
|
||||
fi.DiskModTime = info.ModTime().UTC().Format("2006-01-02 15:04:05 UTC")
|
||||
|
||||
switch entry.Type {
|
||||
case "file":
|
||||
hash, err := HashFile(entryAbs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hashing %s: %w", entryAbs, err)
|
||||
}
|
||||
fi.CurrentHash = hash
|
||||
|
||||
compareHash := entry.Hash
|
||||
if entry.Encrypted && entry.PlaintextHash != "" {
|
||||
compareHash = entry.PlaintextHash
|
||||
}
|
||||
if hash != compareHash {
|
||||
if entry.Locked {
|
||||
fi.State = "drifted"
|
||||
} else {
|
||||
fi.State = "modified"
|
||||
}
|
||||
} else {
|
||||
fi.State = "ok"
|
||||
}
|
||||
|
||||
case "link":
|
||||
target, err := os.Readlink(entryAbs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading symlink %s: %w", entryAbs, err)
|
||||
}
|
||||
fi.CurrentTarget = target
|
||||
if target != entry.Target {
|
||||
fi.State = "modified"
|
||||
} else {
|
||||
fi.State = "ok"
|
||||
}
|
||||
|
||||
case "directory":
|
||||
fi.State = "ok"
|
||||
}
|
||||
|
||||
return fi, nil
|
||||
}
|
||||
|
||||
// resolvePath resolves a user-provided path to an absolute path, handling
|
||||
// tilde expansion and relative paths.
|
||||
func resolvePath(path string) (string, error) {
|
||||
if path == "~" || strings.HasPrefix(path, "~/") {
|
||||
return ExpandTildePath(path)
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// If it looks like a tilde path already, just expand it.
|
||||
if strings.HasPrefix(path, home) {
|
||||
return path, nil
|
||||
}
|
||||
abs, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = abs + "/" + path
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
191
garden/info_test.go
Normal file
191
garden/info_test.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package garden
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInfoTrackedFile(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 track.
|
||||
filePath := filepath.Join(root, "hello.txt")
|
||||
if err := os.WriteFile(filePath, []byte("hello\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
if err := g.Add([]string{filePath}); err != nil {
|
||||
t.Fatalf("Add: %v", err)
|
||||
}
|
||||
|
||||
fi, err := g.Info(filePath)
|
||||
if err != nil {
|
||||
t.Fatalf("Info: %v", err)
|
||||
}
|
||||
|
||||
if fi.Type != "file" {
|
||||
t.Errorf("Type = %q, want %q", fi.Type, "file")
|
||||
}
|
||||
if fi.State != "ok" {
|
||||
t.Errorf("State = %q, want %q", fi.State, "ok")
|
||||
}
|
||||
if fi.Hash == "" {
|
||||
t.Error("Hash is empty")
|
||||
}
|
||||
if fi.CurrentHash == "" {
|
||||
t.Error("CurrentHash is empty")
|
||||
}
|
||||
if fi.Hash != fi.CurrentHash {
|
||||
t.Errorf("Hash = %q != CurrentHash = %q", fi.Hash, fi.CurrentHash)
|
||||
}
|
||||
if fi.Updated == "" {
|
||||
t.Error("Updated is empty")
|
||||
}
|
||||
if fi.DiskModTime == "" {
|
||||
t.Error("DiskModTime is empty")
|
||||
}
|
||||
if !fi.BlobStored {
|
||||
t.Error("BlobStored = false, want true")
|
||||
}
|
||||
if fi.Mode != "0644" {
|
||||
t.Errorf("Mode = %q, want %q", fi.Mode, "0644")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInfoModifiedFile(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
repoDir := filepath.Join(root, "repo")
|
||||
|
||||
g, err := Init(repoDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Init: %v", err)
|
||||
}
|
||||
|
||||
filePath := filepath.Join(root, "hello.txt")
|
||||
if err := os.WriteFile(filePath, []byte("hello\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
if err := g.Add([]string{filePath}); err != nil {
|
||||
t.Fatalf("Add: %v", err)
|
||||
}
|
||||
|
||||
// Modify the file.
|
||||
if err := os.WriteFile(filePath, []byte("changed\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
fi, err := g.Info(filePath)
|
||||
if err != nil {
|
||||
t.Fatalf("Info: %v", err)
|
||||
}
|
||||
|
||||
if fi.State != "modified" {
|
||||
t.Errorf("State = %q, want %q", fi.State, "modified")
|
||||
}
|
||||
if fi.CurrentHash == fi.Hash {
|
||||
t.Error("CurrentHash should differ from Hash after modification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInfoMissingFile(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
repoDir := filepath.Join(root, "repo")
|
||||
|
||||
g, err := Init(repoDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Init: %v", err)
|
||||
}
|
||||
|
||||
filePath := filepath.Join(root, "hello.txt")
|
||||
if err := os.WriteFile(filePath, []byte("hello\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
if err := g.Add([]string{filePath}); err != nil {
|
||||
t.Fatalf("Add: %v", err)
|
||||
}
|
||||
|
||||
// Remove the file.
|
||||
if err := os.Remove(filePath); err != nil {
|
||||
t.Fatalf("Remove: %v", err)
|
||||
}
|
||||
|
||||
fi, err := g.Info(filePath)
|
||||
if err != nil {
|
||||
t.Fatalf("Info: %v", err)
|
||||
}
|
||||
|
||||
if fi.State != "missing" {
|
||||
t.Errorf("State = %q, want %q", fi.State, "missing")
|
||||
}
|
||||
if fi.DiskModTime != "" {
|
||||
t.Errorf("DiskModTime = %q, want empty for missing file", fi.DiskModTime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInfoUntracked(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
repoDir := filepath.Join(root, "repo")
|
||||
|
||||
g, err := Init(repoDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Init: %v", err)
|
||||
}
|
||||
|
||||
filePath := filepath.Join(root, "nope.txt")
|
||||
if err := os.WriteFile(filePath, []byte("nope\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
_, err = g.Info(filePath)
|
||||
if err == nil {
|
||||
t.Fatal("Info should fail for untracked file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInfoSymlink(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.txt")
|
||||
if err := os.WriteFile(target, []byte("target\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
linkPath := filepath.Join(root, "link.txt")
|
||||
if err := os.Symlink(target, linkPath); err != nil {
|
||||
t.Fatalf("Symlink: %v", err)
|
||||
}
|
||||
|
||||
if err := g.Add([]string{linkPath}); err != nil {
|
||||
t.Fatalf("Add: %v", err)
|
||||
}
|
||||
|
||||
fi, err := g.Info(linkPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Info: %v", err)
|
||||
}
|
||||
|
||||
if fi.Type != "link" {
|
||||
t.Errorf("Type = %q, want %q", fi.Type, "link")
|
||||
}
|
||||
if fi.State != "ok" {
|
||||
t.Errorf("State = %q, want %q", fi.State, "ok")
|
||||
}
|
||||
if fi.Target != target {
|
||||
t.Errorf("Target = %q, want %q", fi.Target, target)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user