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
|
||||
}
|
||||
Reference in New Issue
Block a user