Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed
- `mate status`, `mate diff`, and `mate check` now render templates before comparing hashes — variable changes in include files are correctly detected as pending changes

## [0.2.0] - 2026-07-22

### Fixed
Expand Down
18 changes: 17 additions & 1 deletion internal/cli/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import (

"github.com/spf13/cobra"
"github.com/subbeh/statemate/internal/config"
"github.com/subbeh/statemate/internal/encrypt"
"github.com/subbeh/statemate/internal/profile"
"github.com/subbeh/statemate/internal/state"
"github.com/subbeh/statemate/internal/target"
"github.com/subbeh/statemate/internal/template"
"github.com/subbeh/statemate/internal/util"
)

Expand Down Expand Up @@ -78,7 +80,21 @@ func runCheck(cmd *cobra.Command, args []string) error {
}
defer func() { _ = db.Close() }()

changes, err := target.ComputeChanges(tree, db)
var enc *encrypt.AgeEncryptor
if cfg.Age != nil {
enc, _ = encrypt.NewAgeEncryptor(cfg.Age.Identity, cfg.Age.IdentityCommand, cfg.Age.Recipients)
}

var ctxOpts []template.ContextOption
if enc != nil && enc.CanDecrypt() {
ctxOpts = append(ctxOpts, template.WithDecrypt(enc.Decrypt))
}
tmplCtx, _ := template.NewContext(cfg, profileName, ctxOpts...)

changes, err := target.ComputeChanges(tree, db, target.ComputeOpts{
TmplCtx: tmplCtx,
Enc: enc,
})
if err != nil {
return fmt.Errorf("computing changes: %w", err)
}
Expand Down
51 changes: 27 additions & 24 deletions internal/cli/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,33 @@ func runDiff(cmd *cobra.Command, args []string) error {
}
}

changes, err := target.ComputeChanges(tree, db)
var ctxOpts []template.ContextOption
if enc != nil && enc.CanDecrypt() {
ctxOpts = append(ctxOpts, template.WithDecrypt(enc.Decrypt))
}
tmplCtx, err := template.NewContext(cfg, profileName, ctxOpts...)
if err != nil {
return fmt.Errorf("creating template context: %w", err)
}

{
identitySource := ""
if cfg.Age != nil {
identitySource = cfg.Age.Identity
}
mgr, err := secrets.NewManager(enc, identitySource, cfg.SecretsCache)
if err == nil {
tmplCtx.SecretLookup = func(item, typ, field string) (string, error) {
key := secrets.CacheKey{Provider: "bitwarden", Item: item, Type: typ, Field: field}
return mgr.Get(key)
}
}
}

changes, err := target.ComputeChanges(tree, db, target.ComputeOpts{
TmplCtx: tmplCtx,
Enc: enc,
})
if err != nil {
return fmt.Errorf("computing changes: %w", err)
}
Expand All @@ -116,29 +142,6 @@ func runDiff(cmd *cobra.Command, args []string) error {
return nil
}

var ctxOpts []template.ContextOption
if enc != nil && enc.CanDecrypt() {
ctxOpts = append(ctxOpts, template.WithDecrypt(enc.Decrypt))
}
tmplCtx, err := template.NewContext(cfg, profileName, ctxOpts...)
if err != nil {
return fmt.Errorf("creating template context: %w", err)
}

{
identitySource := ""
if cfg.Age != nil {
identitySource = cfg.Age.Identity
}
mgr, err := secrets.NewManager(enc, identitySource, cfg.SecretsCache)
if err == nil {
tmplCtx.SecretLookup = func(item, typ, field string) (string, error) {
key := secrets.CacheKey{Provider: "bitwarden", Item: item, Type: typ, Field: field}
return mgr.Get(key)
}
}
}

diffTool, _ := cmd.Flags().GetString("tool")
if diffTool == "" {
diffTool = cfg.DiffTool
Expand Down
27 changes: 17 additions & 10 deletions internal/cli/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,23 @@ func runStatus(cmd *cobra.Command, args []string) error {
}
defer func() { _ = db.Close() }()

changes, err := target.ComputeChanges(tree, db)
var enc *encrypt.AgeEncryptor
identitySource := ""
if cfg.Age != nil {
enc, _ = encrypt.NewAgeEncryptor(cfg.Age.Identity, cfg.Age.IdentityCommand, cfg.Age.Recipients)
identitySource = cfg.Age.Identity
}

var ctxOpts []template.ContextOption
if enc != nil && enc.CanDecrypt() {
ctxOpts = append(ctxOpts, template.WithDecrypt(enc.Decrypt))
}
tmplCtx, _ := template.NewContext(cfg, profileName, ctxOpts...)

changes, err := target.ComputeChanges(tree, db, target.ComputeOpts{
TmplCtx: tmplCtx,
Enc: enc,
})
if err != nil {
return fmt.Errorf("computing changes: %w", err)
}
Expand All @@ -108,21 +124,12 @@ func runStatus(cmd *cobra.Command, args []string) error {

var pendingSecrets int
{
var enc *encrypt.AgeEncryptor
identitySource := ""
if cfg.Age != nil {
enc, _ = encrypt.NewAgeEncryptor(cfg.Age.Identity, cfg.Age.IdentityCommand, cfg.Age.Recipients)
identitySource = cfg.Age.Identity
}
if mgr, err := secrets.NewManager(enc, identitySource, cfg.SecretsCache); err == nil {
templateFiles := discoverTemplateFiles(cfg, sourcePaths)
var decryptFn func([]byte) ([]byte, error)
var ctxOpts []template.ContextOption
if enc != nil && enc.CanDecrypt() {
decryptFn = enc.Decrypt
ctxOpts = append(ctxOpts, template.WithDecrypt(enc.Decrypt))
}
tmplCtx, _ := template.NewContext(cfg, profileName, ctxOpts...)
items := secrets.DiscoverByRendering(templateFiles, tmplCtx, decryptFn)
cached := mgr.ListCached()
for _, item := range items {
Expand Down
54 changes: 49 additions & 5 deletions internal/target/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import (
"strings"

"github.com/fatih/color"
"github.com/subbeh/statemate/internal/encrypt"
"github.com/subbeh/statemate/internal/source"
"github.com/subbeh/statemate/internal/state"
"github.com/subbeh/statemate/internal/template"
)

type Change struct {
Expand All @@ -33,11 +35,21 @@ func permMismatch(entry *source.Entry, info os.FileInfo) bool {
return info.Mode().Perm() != desiredMode(entry)
}

func ComputeChanges(tree *source.Tree, db *state.DB) ([]*Change, error) {
type ComputeOpts struct {
TmplCtx *template.Context
Enc *encrypt.AgeEncryptor
}

func ComputeChanges(tree *source.Tree, db *state.DB, opts ...ComputeOpts) ([]*Change, error) {
var o ComputeOpts
if len(opts) > 0 {
o = opts[0]
}

var changes []*Change

for _, entry := range tree.Files() {
change, err := computeChange(entry, db)
change, err := computeChange(entry, db, &o)
if err != nil {
return nil, err
}
Expand All @@ -49,7 +61,7 @@ func ComputeChanges(tree *source.Tree, db *state.DB) ([]*Change, error) {
return changes, nil
}

func computeChange(entry *source.Entry, db *state.DB) (*Change, error) {
func computeChange(entry *source.Entry, db *state.DB, opts *ComputeOpts) (*Change, error) {
change := &Change{Entry: entry}

var sourceHash string
Expand All @@ -64,6 +76,13 @@ func computeChange(entry *source.Entry, db *state.DB) (*Change, error) {
}
change.NewHash = sourceHash

renderedHash := sourceHash
if !entry.Generated && (entry.Attrs.Encrypted || entry.Attrs.Template) {
if h, err := getRenderedHash(entry, opts); err == nil {
renderedHash = h
}
}

existing, err := db.GetFile(entry.TargetPath)
if err != nil {
return nil, err
Expand All @@ -87,7 +106,7 @@ func computeChange(entry *source.Entry, db *state.DB) (*Change, error) {
if err != nil {
return nil, err
}
if targetHash != sourceHash {
if targetHash != renderedHash {
change.Status = StatusConflict
change.OldHash = targetHash
} else if permMismatch(entry, info) {
Expand Down Expand Up @@ -126,7 +145,9 @@ func computeChange(entry *source.Entry, db *state.DB) (*Change, error) {

if existing.SourceHash == sourceHash {
if targetHash == existing.AppliedHash {
if permMismatch(entry, info) {
if renderedHash != targetHash {
change.Status = StatusModified
} else if permMismatch(entry, info) {
change.Status = StatusModified
} else {
change.Status = StatusUnchanged
Expand Down Expand Up @@ -255,3 +276,26 @@ func IsBinaryFile(path string) bool {
}
return false
}

func getRenderedHash(entry *source.Entry, opts *ComputeOpts) (string, error) {
content, err := os.ReadFile(entry.SourcePath)
if err != nil {
return "", err
}

if entry.Attrs.Encrypted && opts.Enc != nil {
content, err = opts.Enc.Decrypt(content)
if err != nil {
return "", err
}
}

if entry.Attrs.Template && opts.TmplCtx != nil {
content, err = template.Render(content, opts.TmplCtx)
if err != nil {
return "", err
}
}

return state.HashBytes(content), nil
}
Loading