Repository: twpayne/chezmoi (go)

Issue:
Regenerate chezmoi.toml in chezmoi apply automatically if a chezmoi.toml.tmpl is found on source folder
## Why

I often adjust my `chezmoi.toml.tmpl`. Each time I adjust it, I have to remember to run `chezmoi init`, because `chezmoi apply` does not regenerate it by default.

Apart from the `chezmoi.toml` not being on the _default target directory_, is there a reason for not doing that by default in case a `chezmoi.toml.tmpl` is found on the source folder? If it's the only reason, I think it worth it to consider turning it an exception for the rule.

## Solution

The PR's title says all:

Regenerate `chezmoi.toml` in `chezmoi apply` automatically if a `chezmoi.toml.tmpl` is found on source folder.

## Alternatives

Because of this, I prefer to run `chezmoi init --apply` instead of `chezmoi apply`, and `chezmoi update --apply=false && chezmoi init --apply` instead of `chezmoi update`. A bunch of unnecessary complexity in my point of view.


Pull request description:
Warn when config file template has changed
Fixes #865.

Patch:
diff --git a/chezmoi2/cmd/config.go b/chezmoi2/cmd/config.go
index f257c6b44..6a8e96c39 100644
--- a/chezmoi2/cmd/config.go
+++ b/chezmoi2/cmd/config.go
@@ -2,6 +2,7 @@ package cmd
 
 import (
 	"bytes"
+	"encoding/json"
 	"errors"
 	"fmt"
 	"io"
@@ -143,8 +144,13 @@ type Config struct {
 // A configOption sets and option on a Config.
 type configOption func(*Config) error
 
+type configState struct {
+	ConfigTemplateContentsSHA256 chezmoi.HexBytes `json:"configTemplateContentsSHA256" yaml:"configTemplateContentsSHA256"`
+}
+
 var (
 	persistentStateFilename    = chezmoi.RelPath("chezmoistate.boltdb")
+	configStateKey             = []byte("configState")
 	commitMessageTemplateAsset = "assets/templates/COMMIT_MESSAGE.tmpl"
 
 	identifierRx = regexp.MustCompile(`\A[\pL_][\pL\p{Nd}_]*\z`)
@@ -339,6 +345,40 @@ func (c *Config) applyArgs(targetSystem chezmoi.System, targetDirAbsPath chezmoi
 		return err
 	}
 
+	var currentConfigTemplateContentsSHA256 []byte
+	_, _, configTemplateContents, err := c.findConfigTemplate()
+	if err != nil {
+		return err
+	}
+	currentConfigTemplateContentsSHA256 = chezmoi.SHA256Sum(configTemplateContents)
+	var previousConfigTemplateContentsSHA256 []byte
+	if configStateData, err := c.persistentState.Get(chezmoi.ConfigStateBucket, configStateKey); err != nil {
+		return err
+	} else if configStateData != nil {
+		var configState configState
+		if err := json.Unmarshal(configStateData, &configState); err != nil {
+			return err
+		}
+		previousConfigTemplateContentsSHA256 = []byte(configState.ConfigTemplateContentsSHA256)
+	}
+	configTemplateContentsUnchanged := (currentConfigTemplateContentsSHA256 == nil && previousConfigTemplateContentsSHA256 == nil) ||
+		bytes.Equal(currentConfigTemplateContentsSHA256, previousConfigTemplateContentsSHA256)
+	if !configTemplateContentsUnchanged {
+		if c.force {
+			configStateValue, err := json.Marshal(configState{
+				ConfigTemplateContentsSHA256: chezmoi.HexBytes(currentConfigTemplateContentsSHA256),
+			})
+			if err != nil {
+				return err
+			}
+			if err := c.persistentState.Set(chezmoi.ConfigStateBucket, configStateKey, configStateValue); err != nil {
+				return err
+			}
+		} else {
+			c.errorf("warning: config file template has changed, run chezmoi init to regenerate config file\n")
+		}
+	}
+
 	applyOptions := chezmoi.ApplyOptions{
 		Include:       options.include,
 		PreApplyFunc:  options.preApplyFunc,
@@ -723,6 +763,21 @@ func (c *Config) execute(args []string) error {
 	return rootCmd.Execute()
 }
 
+func (c *Config) findConfigTemplate() (chezmoi.RelPath, string, []byte, error) {
+	for _, ext := range viper.SupportedExts {
+		filename := chezmoi.RelPath(chezmoi.Prefix + "." + ext + chezmoi.TemplateSuffix)
+		contents, err := c.baseSystem.ReadFile(c.sourceDirAbsPath.Join(filename))
+		switch {
+		case os.IsNotExist(err):
+			continue
+		case err != nil:
+			return "", "", nil, err
+		}
+		return chezmoi.RelPath("chezmoi." + ext), ext, contents, nil
+	}
+	return "", "", nil, nil
+}
+
 func (c *Config) gitAutoAdd() (*git.Status, error) {
 	if err := c.run(c.sourceDirAbsPath, c.Git.Command, []string{"add", "."}); err != nil {
 		return nil, err
diff --git a/chezmoi2/cmd/helps.gen.go b/chezmoi2/cmd/helps.gen.go
index 99d5c43e0..e3efb89d4 100644
--- a/chezmoi2/cmd/helps.gen.go
+++ b/chezmoi2/cmd/helps.gen.go
@@ -8,9 +8,30 @@ type help struct {
 }
 
 var helps = map[string]help{
-	"--follow": {
+	"add": {
 		long: "" +
 			"Description:\n" +
+			"  Add *targets* to the source state. If any target is already in the source\n" +
+			"  state, then its source state is replaced with its current state in the\n" +
+			"  destination directory. The `add` command accepts additional flags:\n" +
+			"\n" +
+			"  `--autotemplate`\n" +
+			"\n" +
+			"  Automatically generate a template by replacing strings with variable names\n" +
+			"  from the `data` section of the config file. Longer substitutions occur\n" +
+			"  before shorter ones. This implies the `--template` option.\n" +
+			"\n" +
+			"  `-e`, `--empty`\n" +
+			"\n" +
+			"  Set the `empty` attribute on added files.\n" +
+			"\n" +
+			"  `-f`, `--force`\n" +
+			"\n" +
+			"  Add *targets*, even if doing so would cause a source template to be\n" +
+			"  overwritten.\n" +
+			"\n" +
+			"  `--follow`\n" +
+			"\n" +
 			"  If the last part of a target is a symlink, add the target of the symlink\n" +
 			"  instead of the symlink itself.\n" +
 			"\n" +
@@ -39,28 +60,6 @@ var helps = map[string]help{
 			"  chezmoi add ~/.vim --recursive\n" +
 			"  chezmoi add ~/.oh-my-zsh --exact --recursive",
 	},
-	"add": {
-		long: "" +
-			"Description:\n" +
-			"  Add *targets* to the source state. If any target is already in the source\n" +
-			"  state, then its source state is replaced with its current state in the\n" +
-			"  destination directory. The `add` command accepts additional flags:\n" +
-			"\n" +
-			"  `--autotemplate`\n" +
-			"\n" +
-			"  Automatically generate a template by replacing strings with variable names\n" +
-			"  from the `data` section of the config file. Longer substitutions occur\n" +
-			"  before shorter ones. This implies the `--template` option.\n" +
-			"\n" +
-			"  `-e`, `--empty`\n" +
-			"\n" +
-			"  Set the `empty` attribute on added files.\n" +
-			"\n" +
-			"  `-f`, `--force`\n" +
-			"\n" +
-			"  Add *targets*, even if doing so would cause a source template to be\n" +
-			"  overwritten.",
-	},
 	"apply": {
 		long: "" +
 			"Description:\n" +
diff --git a/chezmoi2/cmd/initcmd.go b/chezmoi2/cmd/initcmd.go
index 8da4f8e4f..eae4e554f 100644
--- a/chezmoi2/cmd/initcmd.go
+++ b/chezmoi2/cmd/initcmd.go
@@ -2,6 +2,7 @@ package cmd
 
 import (
 	"bytes"
+	"encoding/json"
 	"fmt"
 	"os"
 	"regexp"
@@ -99,24 +100,7 @@ func (c *Config) runInitCmd(cmd *cobra.Command, args []string) error {
 		c.init.purge = true
 	}
 
-	if len(args) == 0 {
-		switch useBuiltinGit, err := c.useBuiltinGit(); {
-		case err != nil:
-			return err
-		case useBuiltinGit:
-			rawSourceDir, err := c.baseSystem.RawPath(c.sourceDirAbsPath)
-			if err != nil {
-				return err
-			}
-			isBare := false
-			_, err = git.PlainInit(string(rawSourceDir), isBare)
-			return err
-		default:
-			return c.run(c.sourceDirAbsPath, c.Git.Command, []string{"init"})
-		}
-	}
-
-	// Clone repo into source directory if it does not already exist.
+	// If the source repo does not exist then init or clone it.
 	switch _, err := c.baseSystem.Stat(c.sourceDirAbsPath.Join(chezmoi.RelPath(".git"))); {
 	case os.IsNotExist(err):
 		rawSourceDir, err := c.baseSystem.RawPath(c.sourceDirAbsPath)
@@ -124,35 +108,48 @@ func (c *Config) runInitCmd(cmd *cobra.Command, args []string) error {
 			return err
 		}
 
-		dotfilesRepoURL := guessDotfilesRepoURL(args[0])
-		switch useBuiltinGit, err := c.useBuiltinGit(); {
-		case err != nil:
+		useBuiltinGit, err := c.useBuiltinGit()
+		if err != nil {
 			return err
-		case useBuiltinGit:
-			isBare := false
-			if _, err := git.PlainClone(string(rawSourceDir), isBare, &git.CloneOptions{
-				URL:               dotfilesRepoURL,
-				Depth:             c.init.depth,
-				RecurseSubmodules: git.DefaultSubmoduleRecursionDepth,
-			}); err != nil {
+		}
+
+		if len(args) == 0 {
+			if useBuiltinGit {
+				isBare := false
+				if _, err = git.PlainInit(string(rawSourceDir), isBare); err != nil {
+					return err
+				}
+			} else if err := c.run(c.sourceDirAbsPath, c.Git.Command, []string{"init"}); err != nil {
 				return err
 			}
-		default:
-			args := []string{
-				"clone",
-				"--recurse-submodules",
-			}
-			if c.init.depth != 0 {
+		} else {
+			dotfilesRepoURL := guessDotfilesRepoURL(args[0])
+			if useBuiltinGit {
+				isBare := false
+				if _, err := git.PlainClone(string(rawSourceDir), isBare, &git.CloneOptions{
+					URL:               dotfilesRepoURL,
+					Depth:             c.init.depth,
+					RecurseSubmodules: git.DefaultSubmoduleRecursionDepth,
+				}); err != nil {
+					return err
+				}
+			} else {
+				args := []string{
+					"clone",
+					"--recurse-submodules",
+				}
+				if c.init.depth != 0 {
+					args = append(args,
+						"--depth", strconv.Itoa(c.init.depth),
+					)
+				}
 				args = append(args,
-					"--depth", strconv.Itoa(c.init.depth),
+					dotfilesRepoURL,
+					string(rawSourceDir),
 				)
-			}
-			args = append(args,
-				dotfilesRepoURL,
-				string(rawSourceDir),
-			)
-			if err := c.run("", c.Git.Command, args); err != nil {
-				return err
+				if err := c.run("", c.Git.Command, args); err != nil {
+					return err
+				}
 			}
 		}
 	case err != nil:
@@ -170,6 +167,15 @@ func (c *Config) runInitCmd(cmd *cobra.Command, args []string) error {
 		if err != nil {
 			return err
 		}
+		configStateValue, err := json.Marshal(configState{
+			ConfigTemplateContentsSHA256: chezmoi.HexBytes(chezmoi.SHA256Sum(configFileContents)),
+		})
+		if err != nil {
+			return err
+		}
+		if err := c.persistentState.Set(chezmoi.ConfigStateBucket, configStateKey, configStateValue); err != nil {
+			return err
+		}
 	}
 
 	// Reload config if it was created.
@@ -247,21 +253,6 @@ func (c *Config) createConfigFile(filename chezmoi.RelPath, data []byte) ([]byte
 	return contents, nil
 }
 
-func (c *Config) findConfigTemplate() (chezmoi.RelPath, string, []byte, error) {
-	for _, ext := range viper.SupportedExts {
-		filename := chezmoi.RelPath(chezmoi.Prefix + "." + ext + chezmoi.TemplateSuffix)
-		contents, err := c.baseSystem.ReadFile(c.sourceDirAbsPath.Join(filename))
-		switch {
-		case os.IsNotExist(err):
-			continue
-		case err != nil:
-			return "", "", nil, err
-		}
-		return chezmoi.RelPath("chezmoi." + ext), ext, contents, nil
-	}
-	return "", "", nil, nil
-}
-
 func (c *Config) promptBool(field string) bool {
 	value, err := parseBool(c.promptString(field))
 	if err != nil {
diff --git a/chezmoi2/cmd/reference.gen.go b/chezmoi2/cmd/reference.gen.go
index b526a4554..cea105e90 100644
--- a/chezmoi2/cmd/reference.gen.go
+++ b/chezmoi2/cmd/reference.gen.go
@@ -50,7 +50,6 @@ func init() {
 		"  * [`.chezmoiversion`](#chezmoiversion)\n" +
 		"* [Commands](#commands)\n" +
 		"  * [`add` *targets*](#add-targets)\n" +
-		"  * [`--follow`](#--follow)\n" +
 		"  * [`apply` [*targets*]](#apply-targets)\n" +
 		"  * [`archive`](#archive)\n" +
 		"  * [`cat` *targets*](#cat-targets)\n" +
@@ -551,7 +550,7 @@ func init() {
 		"\n" +
 		"Add *targets*, even if doing so would cause a source template to be overwritten.\n" +
 		"\n" +
-		"### `--follow`\n" +
+		"#### `--follow`\n" +
 		"\n" +
 		"If the last part of a target is a symlink, add the target of the symlink instead\n" +
 		"of the symlink itself.\n" +
diff --git a/chezmoi2/docs/REFERENCE.md b/chezmoi2/docs/REFERENCE.md
index 3fd0cf757..f43b9a4d3 100644
--- a/chezmoi2/docs/REFERENCE.md
+++ b/chezmoi2/docs/REFERENCE.md
@@ -43,7 +43,6 @@ Manage your dotfiles securely across multiple machines.
   * [`.chezmoiversion`](#chezmoiversion)
 * [Commands](#commands)
   * [`add` *targets*](#add-targets)
-  * [`--follow`](#--follow)
   * [`apply` [*targets*]](#apply-targets)
   * [`archive`](#archive)
   * [`cat` *targets*](#cat-targets)
@@ -544,7 +543,7 @@ Set the `empty` attribute on added files.
 
 Add *targets*, even if doing so would cause a source template to be overwritten.
 
-### `--follow`
+#### `--follow`
 
 If the last part of a target is a symlink, add the target of the symlink instead
 of the symlink itself.
diff --git a/chezmoi2/internal/chezmoi/actualstateentry.go b/chezmoi2/internal/chezmoi/actualstateentry.go
index 84c4239f0..aca52b8e9 100644
--- a/chezmoi2/internal/chezmoi/actualstateentry.go
+++ b/chezmoi2/internal/chezmoi/actualstateentry.go
@@ -136,7 +136,7 @@ func (s *ActualStateFile) EntryState() (*EntryState, error) {
 	return &EntryState{
 		Type:           EntryStateTypeFile,
 		Mode:           s.perm,
-		ContentsSHA256: hexBytes(contentsSHA256),
+		ContentsSHA256: HexBytes(contentsSHA256),
 		contents:       contents,
 	}, nil
 }
@@ -163,7 +163,7 @@ func (s *ActualStateSymlink) EntryState() (*EntryState, error) {
 	}
 	return &EntryState{
 		Type:           EntryStateTypeSymlink,
-		ContentsSHA256: hexBytes(linknameSHA256),
+		ContentsSHA256: HexBytes(linknameSHA256),
 		contents:       []byte(linkname),
 	}, nil
 }
diff --git a/chezmoi2/internal/chezmoi/chezmoi.go b/chezmoi2/internal/chezmoi/chezmoi.go
index c7069d81f..d39469c87 100644
--- a/chezmoi2/internal/chezmoi/chezmoi.go
+++ b/chezmoi2/internal/chezmoi/chezmoi.go
@@ -2,6 +2,7 @@ package chezmoi
 
 import (
 	"bytes"
+	"crypto/sha256"
 	"fmt"
 	"os"
 	"path/filepath"
@@ -109,6 +110,12 @@ func (e *errUnsupportedFileType) Error() string {
 	return fmt.Sprintf("%s: unsupported file type %s", e.absPath, modeTypeName(e.mode))
 }
 
+// SHA256Sum returns the SHA256 sum of data.
+func SHA256Sum(data []byte) []byte {
+	sha256SumArr := sha256.Sum256(data)
+	return sha256SumArr[:]
+}
+
 // SuspiciousSourceDirEntry returns true if base is a suspicous dir entry.
 func SuspiciousSourceDirEntry(base string, info os.FileInfo) bool {
 	//nolint:exhaustive
diff --git a/chezmoi2/internal/chezmoi/entrystate.go b/chezmoi2/internal/chezmoi/entrystate.go
index 744b78e4d..db2960e8d 100644
--- a/chezmoi2/internal/chezmoi/entrystate.go
+++ b/chezmoi2/internal/chezmoi/entrystate.go
@@ -23,7 +23,7 @@ const (
 type EntryState struct {
 	Type           EntryStateType `json:"type" toml:"type" yaml:"type"`
 	Mode           os.FileMode    `json:"mode,omitempty" toml:"mode,omitempty" yaml:"mode,omitempty"`
-	ContentsSHA256 hexBytes       `json:"contentsSHA256,omitempty" toml:"contentsSHA256,omitempty" yaml:"contentsSHA256,omitempty"`
+	ContentsSHA256 HexBytes       `json:"contentsSHA256,omitempty" toml:"contentsSHA256,omitempty" yaml:"contentsSHA256,omitempty"`
 	contents       []byte
 }
 
diff --git a/chezmoi2/internal/chezmoi/hexbytes.go b/chezmoi2/internal/chezmoi/hexbytes.go
index 22e285f00..bc27ff213 100644
--- a/chezmoi2/internal/chezmoi/hexbytes.go
+++ b/chezmoi2/internal/chezmoi/hexbytes.go
@@ -4,11 +4,11 @@ import (
 	"encoding/hex"
 )
 
-// A hexBytes is a []byte which is marhsaled as a hex string.
-type hexBytes []byte
+// A HexBytes is a []byte which is marhsaled as a hex string.
+type HexBytes []byte
 
 // MarshalText implements encoding.TextMarshaler.MarshalText.
-func (h hexBytes) MarshalText() ([]byte, error) {
+func (h HexBytes) MarshalText() ([]byte, error) {
 	if len(h) == 0 {
 		return nil, nil
 	}
@@ -18,7 +18,7 @@ func (h hexBytes) MarshalText() ([]byte, error) {
 }
 
 // UnmarshalText implements encoding.TextMarshaler.UnmarshalText.
-func (h *hexBytes) UnmarshalText(text []byte) error {
+func (h *HexBytes) UnmarshalText(text []byte) error {
 	if len(text) == 0 {
 		*h = nil
 		return nil
diff --git a/chezmoi2/internal/chezmoi/lazy.go b/chezmoi2/internal/chezmoi/lazy.go
index 515ef7c89..d765f79e1 100644
--- a/chezmoi2/internal/chezmoi/lazy.go
+++ b/chezmoi2/internal/chezmoi/lazy.go
@@ -1,9 +1,5 @@
 package chezmoi
 
-import (
-	"crypto/sha256"
-)
-
 // A contentsFunc is a function that returns the contents of a file or an error.
 // It is typically used for lazy evaluation of a file's contents.
 type contentsFunc func() ([]byte, error)
@@ -44,7 +40,7 @@ func (lc *lazyContents) Contents() ([]byte, error) {
 		lc.contents, lc.contentsErr = lc.contentsFunc()
 		lc.contentsFunc = nil
 		if lc.contentsErr == nil {
-			lc.contentsSHA256 = sha256Sum(lc.contents)
+			lc.contentsSHA256 = SHA256Sum(lc.contents)
 		}
 	}
 	return lc.contents, lc.contentsErr
@@ -53,14 +49,14 @@ func (lc *lazyContents) Contents() ([]byte, error) {
 // ContentsSHA256 returns the SHA256 sum of lc's contents.
 func (lc *lazyContents) ContentsSHA256() ([]byte, error) {
 	if lc == nil {
-		return sha256Sum(nil), nil
+		return SHA256Sum(nil), nil
 	}
 	if lc.contentsSHA256 == nil {
 		contents, err := lc.Contents()
 		if err != nil {
 			return nil, err
 		}
-		lc.contentsSHA256 = sha256Sum(contents)
+		lc.contentsSHA256 = SHA256Sum(contents)
 	}
 	return lc.contentsSHA256, nil
 }
@@ -87,20 +83,14 @@ func (ll *lazyLinkname) Linkname() (string, error) {
 // LinknameSHA256 returns the SHA256 sum of ll's linkname.
 func (ll *lazyLinkname) LinknameSHA256() ([]byte, error) {
 	if ll == nil {
-		return sha256Sum(nil), nil
+		return SHA256Sum(nil), nil
 	}
 	if ll.linknameSHA256 == nil {
 		linkname, err := ll.Linkname()
 		if err != nil {
 			return nil, err
 		}
-		ll.linknameSHA256 = sha256Sum([]byte(linkname))
+		ll.linknameSHA256 = SHA256Sum([]byte(linkname))
 	}
 	return ll.linknameSHA256, nil
 }
-
-// sha256Sum returns the SHA256 sum of data.
-func sha256Sum(data []byte) []byte {
-	sha256SumArr := sha256.Sum256(data)
-	return sha256SumArr[:]
-}
diff --git a/chezmoi2/internal/chezmoi/persistentstate.go b/chezmoi2/internal/chezmoi/persistentstate.go
index 45a1a4e5d..2713fc82f 100644
--- a/chezmoi2/internal/chezmoi/persistentstate.go
+++ b/chezmoi2/internal/chezmoi/persistentstate.go
@@ -1,6 +1,9 @@
 package chezmoi
 
 var (
+	// ConfigStateBucket is the bucket for recording the config state.
+	ConfigStateBucket = []byte("configState")
+
 	// entryStateBucket is the bucket for recording the entry states.
 	entryStateBucket = []byte("entryState")
 
@@ -23,6 +26,10 @@ type PersistentState interface {
 
 // PersistentStateData returns the structured data in s.
 func PersistentStateData(s PersistentState) (interface{}, error) {
+	configStateData, err := persistentStateBucketData(s, ConfigStateBucket)
+	if err != nil {
+		return nil, err
+	}
 	entryStateData, err := persistentStateBucketData(s, entryStateBucket)
 	if err != nil {
 		return nil, err
@@ -32,9 +39,11 @@ func PersistentStateData(s PersistentState) (interface{}, error) {
 		return nil, err
 	}
 	return struct {
+		ConfigState interface{} `json:"configState" toml:"configState" yaml:"configState"`
 		EntryState  interface{} `json:"entryState" toml:"entryState" yaml:"entryState"`
 		ScriptState interface{} `json:"scriptState" toml:"scriptState" yaml:"scriptState"`
 	}{
+		ConfigState: configStateData,
 		EntryState:  entryStateData,
 		ScriptState: scriptStateData,
 	}, nil
diff --git a/chezmoi2/internal/chezmoi/targetstateentry.go b/chezmoi2/internal/chezmoi/targetstateentry.go
index 1b13997cd..e8f5b8a04 100644
--- a/chezmoi2/internal/chezmoi/targetstateentry.go
+++ b/chezmoi2/internal/chezmoi/targetstateentry.go
@@ -130,7 +130,7 @@ func (t *TargetStateFile) EntryState(umask os.FileMode) (*EntryState, error) {
 	return &EntryState{
 		Type:           EntryStateTypeFile,
 		Mode:           t.perm &^ umask,
-		ContentsSHA256: hexBytes(contentsSHA256),
+		ContentsSHA256: HexBytes(contentsSHA256),
 		contents:       contents,
 	}, nil
 }
@@ -210,7 +210,7 @@ func (t *TargetStateScript) EntryState(umask os.FileMode) (*EntryState, error) {
 	}
 	return &EntryState{
 		Type:           EntryStateTypeScript,
-		ContentsSHA256: hexBytes(contentsSHA256),
+		ContentsSHA256: HexBytes(contentsSHA256),
 	}, nil
 }
 


Test patch:
diff --git a/chezmoi2/internal/chezmoi/hexbytes_test.go b/chezmoi2/internal/chezmoi/hexbytes_test.go
index bfbd6f44b..27bec9d33 100644
--- a/chezmoi2/internal/chezmoi/hexbytes_test.go
+++ b/chezmoi2/internal/chezmoi/hexbytes_test.go
@@ -10,7 +10,7 @@ import (
 
 func TestHexBytes(t *testing.T) {
 	for i, tc := range []struct {
-		b           hexBytes
+		b           HexBytes
 		expectedStr string
 	}{
 		{
@@ -35,7 +35,7 @@ func TestHexBytes(t *testing.T) {
 					actual, err := format.Marshal(tc.b)
 					require.NoError(t, err)
 					assert.Equal(t, []byte(tc.expectedStr), actual)
-					var actualB hexBytes
+					var actualB HexBytes
 					require.NoError(t, format.Unmarshal(actual, &actualB))
 					assert.Equal(t, tc.b, actualB)
 				})
diff --git a/chezmoi2/testdata/scripts/configstate.txt b/chezmoi2/testdata/scripts/configstate.txt
new file mode 100644
index 000000000..e00f4f092
--- /dev/null
+++ b/chezmoi2/testdata/scripts/configstate.txt
@@ -0,0 +1,42 @@
+# test that chezmoi init creates a config file and updates the state
+chezmoi init
+cmp $CHEZMOICONFIGDIR/chezmoi.toml golden/chezmoi.toml
+chezmoi state dump --format=yaml
+cmp stdout golden/state-dump.yaml
+
+# test that chezmoi apply succeeds
+chezmoi apply
+
+# test that chezmoi apply prints a warning if the config file template has been changed
+edit $CHEZMOISOURCEDIR/.chezmoi.toml.tmpl
+chezmoi apply
+stderr 'warning: config file template has changed'
+
+# test that chezmoi init re-generates the config file
+chezmoi init
+grep '# edited' $CHEZMOICONFIGDIR/chezmoi.toml
+
+# test that chezmoi apply no longer prints a warning after the config file is regenerated
+chezmoi apply
+! stderr .
+
+# test that chezmoi apply --force ignores config file changes and updates the state
+edit $CHEZMOISOURCEDIR/.chezmoi.toml.tmpl
+grep -count=2 '# edited' $CHEZMOISOURCEDIR/.chezmoi.toml.tmpl
+chezmoi apply --force
+! stderr .
+grep -count=1 '# edited' $CHEZMOICONFIGDIR/chezmoi.toml
+
+-- golden/chezmoi.toml --
+[data]
+  email = "user@home.org"
+-- golden/state-dump.yaml --
+configState:
+    configState:
+        configTemplateContentsSHA256: f79e9af9aab3822d28ba929391ebc14882169abd8613aec85c5a60d46d58ee32
+entryState: {}
+scriptState: {}
+-- home/user/.local/share/chezmoi/.chezmoi.toml.tmpl --
+[data]
+  email = "user@home.org"
+-- home/user/.local/share/chezmoi/.git/.keep --
diff --git a/chezmoi2/testdata/scripts/state_unix.txt b/chezmoi2/testdata/scripts/state_unix.txt
index 5d0b4322f..77c8751de 100644
--- a/chezmoi2/testdata/scripts/state_unix.txt
+++ b/chezmoi2/testdata/scripts/state_unix.txt
@@ -19,6 +19,7 @@ chezmoi --force state reset
 ! exists $CHEZMOICONFIGDIR/chezmoistate.boltdb
 
 -- golden/dump --
+configState: {}
 entryState: {}
 scriptState: {}
 -- home/user/.local/share/chezmoi/run_once_script --
diff --git a/chezmoi2/testdata/scripts/state_windows.txt b/chezmoi2/testdata/scripts/state_windows.txt
index f84673b51..b295113e3 100644
--- a/chezmoi2/testdata/scripts/state_windows.txt
+++ b/chezmoi2/testdata/scripts/state_windows.txt
@@ -19,6 +19,7 @@ chezmoi --force state reset
 ! exists $CHEZMOICONFIGDIR/chezmoistate.boltdb
 
 -- golden/dump --
+configState: {}
 entryState: {}
 scriptState: {}
 -- home/user/.local/share/chezmoi/run_once_script.cmd --