From 5cadb8e87e9116a5dd62f609cd2d1ed8fc41ebab Mon Sep 17 00:00:00 2001 From: Steven Terwindt Date: Thu, 23 Jul 2026 06:49:54 +1000 Subject: [PATCH] fix(target): render templates before comparing in status/diff/check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ComputeChanges only compared raw source file hashes, so changing a variable in an include file produced no visible diff (the template source hadn't changed). Now it accepts an optional template context and encryptor, renders template/encrypted files before hashing, and compares the rendered hash against the target — matching what the apply path already did. --- CHANGELOG.md | 3 +++ internal/cli/check.go | 18 +++++++++++++- internal/cli/diff.go | 51 ++++++++++++++++++++------------------ internal/cli/status.go | 27 +++++++++++++-------- internal/target/diff.go | 54 +++++++++++++++++++++++++++++++++++++---- 5 files changed, 113 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f82600f..d6f82b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/internal/cli/check.go b/internal/cli/check.go index 0c7b27d..26f7cea 100644 --- a/internal/cli/check.go +++ b/internal/cli/check.go @@ -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" ) @@ -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) } diff --git a/internal/cli/diff.go b/internal/cli/diff.go index 03ce4e1..18e6c04 100644 --- a/internal/cli/diff.go +++ b/internal/cli/diff.go @@ -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) } @@ -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 diff --git a/internal/cli/status.go b/internal/cli/status.go index f09349c..a215b72 100644 --- a/internal/cli/status.go +++ b/internal/cli/status.go @@ -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) } @@ -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 { diff --git a/internal/target/diff.go b/internal/target/diff.go index e64be33..fae5e29 100644 --- a/internal/target/diff.go +++ b/internal/target/diff.go @@ -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 { @@ -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 } @@ -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 @@ -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 @@ -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) { @@ -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 @@ -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 +}