Repository: twpayne/chezmoi
Pull request #478 (2019-11-19 20:39:33)

Add debug code


Patch:
diff --git a/cmd/add.go b/cmd/add.go
index 006789716..45e99c4f6 100644
--- a/cmd/add.go
+++ b/cmd/add.go
@@ -19,8 +19,8 @@ var addCmd = &cobra.Command{
 	Long:     mustGetLongHelp("add"),
 	Example:  getExample("add"),
 	PreRunE:  config.ensureNoError,
-	RunE:     makeRunE(config.runAddCmd),
-	PostRunE: makeRunE(config.autoCommitAndAutoPush),
+	RunE:     config.runAddCmd,
+	PostRunE: config.autoCommitAndAutoPush,
 }
 
 type addCmdConfig struct {
@@ -42,13 +42,12 @@ func init() {
 	persistentFlags.BoolVarP(&config.add.options.AutoTemplate, "autotemplate", "a", false, "auto generate the template when adding files as templates")
 }
 
-func (c *Config) runAddCmd(fs vfs.FS, args []string) (err error) {
-	ts, err := c.getTargetState(fs, nil)
+func (c *Config) runAddCmd(cmd *cobra.Command, args []string) (err error) {
+	ts, err := c.getTargetState(nil)
 	if err != nil {
 		return err
 	}
-	mutator := c.getDefaultMutator(fs)
-	if err := c.ensureSourceDirectory(fs, mutator); err != nil {
+	if err := c.ensureSourceDirectory(); err != nil {
 		return err
 	}
 	destDirPrefix := filepath.FromSlash(ts.DestDir + "/")
@@ -68,7 +67,7 @@ func (c *Config) runAddCmd(fs vfs.FS, args []string) (err error) {
 			return err
 		}
 		if c.add.recursive {
-			if err := vfs.Walk(fs, path, func(path string, info os.FileInfo, err error) error {
+			if err := vfs.Walk(c.fs, path, func(path string, info os.FileInfo, err error) error {
 				if err != nil {
 					return err
 				}
@@ -90,7 +89,7 @@ func (c *Config) runAddCmd(fs vfs.FS, args []string) (err error) {
 						c.add.prompt = false
 					}
 				}
-				return ts.Add(fs, c.add.options, path, info, c.Follow, mutator)
+				return ts.Add(c.fs, c.add.options, path, info, c.Follow, c.mutator)
 			}); err != nil {
 				return err
 			}
@@ -113,7 +112,7 @@ func (c *Config) runAddCmd(fs vfs.FS, args []string) (err error) {
 					c.add.prompt = false
 				}
 			}
-			if err := ts.Add(fs, c.add.options, path, nil, c.Follow, mutator); err != nil {
+			if err := ts.Add(c.fs, c.add.options, path, nil, c.Follow, c.mutator); err != nil {
 				return err
 			}
 		}
diff --git a/cmd/apply.go b/cmd/apply.go
index 0f7d3110a..db8855717 100644
--- a/cmd/apply.go
+++ b/cmd/apply.go
@@ -2,7 +2,6 @@ package cmd
 
 import (
 	"github.com/spf13/cobra"
-	vfs "github.com/twpayne/go-vfs"
 )
 
 var applyCmd = &cobra.Command{
@@ -11,21 +10,19 @@ var applyCmd = &cobra.Command{
 	Long:    mustGetLongHelp("apply"),
 	Example: getExample("apply"),
 	PreRunE: config.ensureNoError,
-	RunE:    makeRunE(config.runApplyCmd),
+	RunE:    config.runApplyCmd,
 }
 
 func init() {
 	rootCmd.AddCommand(applyCmd)
 }
 
-func (c *Config) runApplyCmd(fs vfs.FS, args []string) error {
-	mutator := c.getDefaultMutator(fs)
-
-	persistentState, err := c.getPersistentState(fs, nil)
+func (c *Config) runApplyCmd(cmd *cobra.Command, args []string) error {
+	persistentState, err := c.getPersistentState(nil)
 	if err != nil {
 		return err
 	}
 	defer persistentState.Close()
 
-	return c.applyArgs(fs, args, mutator, persistentState)
+	return c.applyArgs(args, persistentState)
 }
diff --git a/cmd/archive.go b/cmd/archive.go
index e0a6149c3..bc68b101f 100644
--- a/cmd/archive.go
+++ b/cmd/archive.go
@@ -5,7 +5,6 @@ import (
 	"os"
 
 	"github.com/spf13/cobra"
-	vfs "github.com/twpayne/go-vfs"
 )
 
 var archiveCmd = &cobra.Command{
@@ -15,15 +14,15 @@ var archiveCmd = &cobra.Command{
 	Long:    mustGetLongHelp("archive"),
 	Example: getExample("archive"),
 	PreRunE: config.ensureNoError,
-	RunE:    makeRunE(config.runArchiveCmd),
+	RunE:    config.runArchiveCmd,
 }
 
 func init() {
 	rootCmd.AddCommand(archiveCmd)
 }
 
-func (c *Config) runArchiveCmd(fs vfs.FS, args []string) error {
-	ts, err := c.getTargetState(fs, nil)
+func (c *Config) runArchiveCmd(cmd *cobra.Command, args []string) error {
+	ts, err := c.getTargetState(nil)
 	if err != nil {
 		return err
 	}
diff --git a/cmd/cat.go b/cmd/cat.go
index 1d30b46a2..7e4e00377 100644
--- a/cmd/cat.go
+++ b/cmd/cat.go
@@ -5,7 +5,6 @@ import (
 
 	"github.com/spf13/cobra"
 	"github.com/twpayne/chezmoi/internal/chezmoi"
-	vfs "github.com/twpayne/go-vfs"
 )
 
 var catCmd = &cobra.Command{
@@ -15,19 +14,19 @@ var catCmd = &cobra.Command{
 	Long:    mustGetLongHelp("cat"),
 	Example: getExample("cat"),
 	PreRunE: config.ensureNoError,
-	RunE:    makeRunE(config.runCatCmd),
+	RunE:    config.runCatCmd,
 }
 
 func init() {
 	rootCmd.AddCommand(catCmd)
 }
 
-func (c *Config) runCatCmd(fs vfs.FS, args []string) error {
-	ts, err := c.getTargetState(fs, nil)
+func (c *Config) runCatCmd(cmd *cobra.Command, args []string) error {
+	ts, err := c.getTargetState(nil)
 	if err != nil {
 		return err
 	}
-	entries, err := c.getEntries(fs, ts, args)
+	entries, err := c.getEntries(ts, args)
 	if err != nil {
 		return err
 	}
diff --git a/cmd/cd.go b/cmd/cd.go
index da2df5cec..4f4c56855 100644
--- a/cmd/cd.go
+++ b/cmd/cd.go
@@ -1,6 +1,9 @@
 package cmd
 
-import "github.com/spf13/cobra"
+import (
+	"github.com/spf13/cobra"
+	"github.com/twpayne/go-shell"
+)
 
 var cdCmd = &cobra.Command{
 	Use:     "cd",
@@ -9,9 +12,18 @@ var cdCmd = &cobra.Command{
 	Long:    mustGetLongHelp("cd"),
 	Example: getExample("cd"),
 	PreRunE: config.ensureNoError,
-	RunE:    makeRunE(config.runCDCmd),
+	RunE:    config.runCDCmd,
 }
 
 func init() {
 	rootCmd.AddCommand(cdCmd)
 }
+
+func (c *Config) runCDCmd(cmd *cobra.Command, args []string) error {
+	if err := c.ensureSourceDirectory(); err != nil {
+		return err
+	}
+
+	shell, _ := shell.CurrentUserShell()
+	return c.run(c.SourceDir, shell)
+}
diff --git a/cmd/cd_posix.go b/cmd/cd_posix.go
deleted file mode 100644
index 29a162d13..000000000
--- a/cmd/cd_posix.go
+++ /dev/null
@@ -1,23 +0,0 @@
-// +build !windows
-
-package cmd
-
-import (
-	"os"
-
-	shell "github.com/twpayne/go-shell"
-	vfs "github.com/twpayne/go-vfs"
-)
-
-func (c *Config) runCDCmd(fs vfs.FS, args []string) error {
-	mutator := c.getDefaultMutator(fs)
-	if err := c.ensureSourceDirectory(fs, mutator); err != nil {
-		return err
-	}
-
-	shell, _ := shell.CurrentUserShell()
-	if err := os.Chdir(c.SourceDir); err != nil {
-		return err
-	}
-	return c.exec(fs, []string{shell})
-}
diff --git a/cmd/cd_windows.go b/cmd/cd_windows.go
deleted file mode 100644
index 8c5799f4f..000000000
--- a/cmd/cd_windows.go
+++ /dev/null
@@ -1,18 +0,0 @@
-// +build windows
-
-package cmd
-
-import (
-	shell "github.com/twpayne/go-shell"
-	vfs "github.com/twpayne/go-vfs"
-)
-
-func (c *Config) runCDCmd(fs vfs.FS, args []string) error {
-	mutator := c.getDefaultMutator(fs)
-	if err := c.ensureSourceDirectory(fs, mutator); err != nil {
-		return err
-	}
-
-	shell, _ := shell.CurrentUserShell()
-	return c.run(fs, c.SourceDir, shell)
-}
diff --git a/cmd/chattr.go b/cmd/chattr.go
index 4da900649..34151b908 100644
--- a/cmd/chattr.go
+++ b/cmd/chattr.go
@@ -9,7 +9,6 @@ import (
 
 	"github.com/spf13/cobra"
 	"github.com/twpayne/chezmoi/internal/chezmoi"
-	vfs "github.com/twpayne/go-vfs"
 )
 
 var chattrCmd = &cobra.Command{
@@ -19,7 +18,7 @@ var chattrCmd = &cobra.Command{
 	Long:    mustGetLongHelp("chattr"),
 	Example: getExample("chattr"),
 	PreRunE: config.ensureNoError,
-	RunE:    makeRunE(config.runChattrCmd),
+	RunE:    config.runChattrCmd,
 }
 
 type boolModifier int
@@ -37,24 +36,22 @@ func init() {
 	rootCmd.AddCommand(chattrCmd)
 }
 
-func (c *Config) runChattrCmd(fs vfs.FS, args []string) error {
+func (c *Config) runChattrCmd(cmd *cobra.Command, args []string) error {
 	ams, err := parseAttributeModifiers(args[0])
 	if err != nil {
 		return err
 	}
 
-	ts, err := c.getTargetState(fs, nil)
+	ts, err := c.getTargetState(nil)
 	if err != nil {
 		return err
 	}
 
-	entries, err := c.getEntries(fs, ts, args[1:])
+	entries, err := c.getEntries(ts, args[1:])
 	if err != nil {
 		return err
 	}
 
-	mutator := c.getDefaultMutator(fs)
-
 	updates := make(map[string]func() error)
 	for _, entry := range entries {
 		dir, oldBase := filepath.Split(entry.SourceName())
@@ -72,7 +69,7 @@ func (c *Config) runChattrCmd(fs vfs.FS, args []string) error {
 			if newBase != oldBase {
 				newpath := filepath.Join(ts.SourceDir, dir, newBase)
 				updates[oldpath] = func() error {
-					return mutator.Rename(oldpath, newpath)
+					return c.mutator.Rename(oldpath, newpath)
 				}
 			}
 		case *chezmoi.File:
@@ -90,7 +87,7 @@ func (c *Config) runChattrCmd(fs vfs.FS, args []string) error {
 			fa.Template = ams.template.modify(entry.Template)
 			newpath := filepath.Join(ts.SourceDir, dir, fa.SourceName())
 			if fa.Encrypted != entry.Encrypted {
-				oldContents, err := fs.ReadFile(filepath.Join(c.SourceDir, entry.SourceName()))
+				oldContents, err := c.fs.ReadFile(filepath.Join(c.SourceDir, entry.SourceName()))
 				if err != nil {
 					return err
 				}
@@ -106,14 +103,14 @@ func (c *Config) runChattrCmd(fs vfs.FS, args []string) error {
 				updates[oldpath] = func() error {
 					// FIXME replace file and contents atomically, see
 					// https://github.com/google/renameio/issues/16.
-					if err := mutator.WriteFile(newpath, newContents, 0644, oldContents); err != nil {
+					if err := c.mutator.WriteFile(newpath, newContents, 0644, oldContents); err != nil {
 						return err
 					}
-					return mutator.RemoveAll(oldpath)
+					return c.mutator.RemoveAll(oldpath)
 				}
 			} else if newpath != oldpath {
 				updates[oldpath] = func() error {
-					return mutator.Rename(oldpath, newpath)
+					return c.mutator.Rename(oldpath, newpath)
 				}
 			}
 		case *chezmoi.Symlink:
@@ -123,7 +120,7 @@ func (c *Config) runChattrCmd(fs vfs.FS, args []string) error {
 			if newBase != oldBase {
 				newpath := filepath.Join(ts.SourceDir, dir, newBase)
 				updates[oldpath] = func() error {
-					return mutator.Rename(oldpath, newpath)
+					return c.mutator.Rename(oldpath, newpath)
 				}
 			}
 		}
diff --git a/cmd/completion.go b/cmd/completion.go
index 37acfa1d3..ae3ce897b 100644
--- a/cmd/completion.go
+++ b/cmd/completion.go
@@ -4,7 +4,6 @@ import (
 	"errors"
 
 	"github.com/spf13/cobra"
-	vfs "github.com/twpayne/go-vfs"
 )
 
 var completionCmd = &cobra.Command{
@@ -14,14 +13,14 @@ var completionCmd = &cobra.Command{
 	Long:      mustGetLongHelp("completion"),
 	Example:   getExample("completion"),
 	ValidArgs: []string{"bash", "fish", "zsh"},
-	RunE:      makeRunE(config.runCompletion),
+	RunE:      config.runCompletion,
 }
 
 func init() {
 	rootCmd.AddCommand(completionCmd)
 }
 
-func (c *Config) runCompletion(fs vfs.FS, args []string) error {
+func (c *Config) runCompletion(cmd *cobra.Command, args []string) error {
 	switch args[0] {
 	case "bash":
 		return rootCmd.GenBashCompletion(c.Stdout())
diff --git a/cmd/config.go b/cmd/config.go
index 53f635d6d..1e3522850 100644
--- a/cmd/config.go
+++ b/cmd/config.go
@@ -39,6 +39,8 @@ type sourceVCSConfig struct {
 type Config struct {
 	configFile        string
 	err               error
+	fs                vfs.FS
+	mutator           chezmoi.Mutator
 	SourceDir         string
 	DestDir           string
 	Umask             permValue
@@ -47,17 +49,18 @@ type Config struct {
 	Remove            bool
 	Verbose           bool
 	Color             string
+	Debug             bool
 	GPG               chezmoi.GPG
 	GPGRecipient      string
 	SourceVCS         sourceVCSConfig
 	Merge             mergeConfig
 	Bitwarden         bitwardenCmdConfig
 	GenericSecret     genericSecretCmdConfig
+	Gopass            gopassCmdConfig
 	KeePassXC         keePassXCCmdConfig
 	Lastpass          lastpassCmdConfig
 	Onepassword       onepasswordCmdConfig
 	Vault             vaultCmdConfig
-	Gopass            gopassCmdConfig
 	Pass              passCmdConfig
 	Data              map[string]interface{}
 	colored           bool
@@ -66,8 +69,8 @@ type Config struct {
 	data              dataCmdConfig
 	dump              dumpCmdConfig
 	edit              editCmdConfig
-	init              initCmdConfig
 	_import           importCmdConfig
+	init              initCmdConfig
 	keyring           keyringCmdConfig
 	remove            removeCmdConfig
 	update            updateCmdConfig
@@ -138,9 +141,9 @@ func (c *Config) addTemplateFunc(key string, value interface{}) {
 	c.templateFuncs[key] = value
 }
 
-func (c *Config) applyArgs(fs vfs.FS, args []string, mutator chezmoi.Mutator, persistentState chezmoi.PersistentState) error {
-	fs = vfs.NewReadOnlyFS(fs)
-	ts, err := c.getTargetState(fs, nil)
+func (c *Config) applyArgs(args []string, persistentState chezmoi.PersistentState) error {
+	fs := vfs.NewReadOnlyFS(c.fs)
+	ts, err := c.getTargetState(nil)
 	if err != nil {
 		return err
 	}
@@ -156,29 +159,29 @@ func (c *Config) applyArgs(fs vfs.FS, args []string, mutator chezmoi.Mutator, pe
 		Verbose:           c.Verbose,
 	}
 	if len(args) == 0 {
-		return ts.Apply(fs, mutator, c.Follow, applyOptions)
+		return ts.Apply(fs, c.mutator, c.Follow, applyOptions)
 	}
-	entries, err := c.getEntries(fs, ts, args)
+	entries, err := c.getEntries(ts, args)
 	if err != nil {
 		return err
 	}
 	for _, entry := range entries {
-		if err := entry.Apply(fs, mutator, c.Follow, applyOptions); err != nil {
+		if err := entry.Apply(fs, c.mutator, c.Follow, applyOptions); err != nil {
 			return err
 		}
 	}
 	return nil
 }
 
-func (c *Config) autoCommit(fs vfs.FS, vcs VCS) error {
+func (c *Config) autoCommit(vcs VCS) error {
 	addArgs := vcs.AddArgs(".")
 	if addArgs == nil {
 		return fmt.Errorf("%s: autocommit not supported", c.SourceVCS.Command)
 	}
-	if err := c.run(fs, c.SourceDir, c.SourceVCS.Command, addArgs...); err != nil {
+	if err := c.run(c.SourceDir, c.SourceVCS.Command, addArgs...); err != nil {
 		return err
 	}
-	output, err := c.output(fs, c.SourceDir, c.SourceVCS.Command, vcs.StatusArgs()...)
+	output, err := c.output(c.SourceDir, c.SourceVCS.Command, vcs.StatusArgs()...)
 	if err != nil {
 		return err
 	}
@@ -199,10 +202,10 @@ func (c *Config) autoCommit(fs vfs.FS, vcs VCS) error {
 		return err
 	}
 	commitArgs := vcs.CommitArgs(b.String())
-	return c.run(fs, c.SourceDir, c.SourceVCS.Command, commitArgs...)
+	return c.run(c.SourceDir, c.SourceVCS.Command, commitArgs...)
 }
 
-func (c *Config) autoCommitAndAutoPush(fs vfs.FS, args []string) error {
+func (c *Config) autoCommitAndAutoPush(cmd *cobra.Command, args []string) error {
 	vcs, err := c.getVCS()
 	if err != nil {
 		return err
@@ -211,24 +214,24 @@ func (c *Config) autoCommitAndAutoPush(fs vfs.FS, args []string) error {
 		return nil
 	}
 	if c.SourceVCS.AutoCommit || c.SourceVCS.AutoPush {
-		if err := c.autoCommit(fs, vcs); err != nil {
+		if err := c.autoCommit(vcs); err != nil {
 			return err
 		}
 	}
 	if c.SourceVCS.AutoPush {
-		if err := c.autoPush(fs, vcs); err != nil {
+		if err := c.autoPush(vcs); err != nil {
 			return err
 		}
 	}
 	return nil
 }
 
-func (c *Config) autoPush(fs vfs.FS, vcs VCS) error {
+func (c *Config) autoPush(vcs VCS) error {
 	pushArgs := vcs.PushArgs()
 	if pushArgs == nil {
 		return fmt.Errorf("%s: autopush not supported", c.SourceVCS.Command)
 	}
-	return c.run(fs, c.SourceDir, c.SourceVCS.Command, pushArgs...)
+	return c.run(c.SourceDir, c.SourceVCS.Command, pushArgs...)
 }
 
 // ensureNoError ensures that no error was encountered when loading c.
@@ -239,25 +242,25 @@ func (c *Config) ensureNoError(cmd *cobra.Command, args []string) error {
 	return nil
 }
 
-func (c *Config) ensureSourceDirectory(fs chezmoi.PrivacyStater, mutator chezmoi.Mutator) error {
-	if err := vfs.MkdirAll(mutator, filepath.Dir(c.SourceDir), 0777&^os.FileMode(c.Umask)); err != nil {
+func (c *Config) ensureSourceDirectory() error {
+	if err := vfs.MkdirAll(c.mutator, filepath.Dir(c.SourceDir), 0777&^os.FileMode(c.Umask)); err != nil {
 		return err
 	}
-	info, err := fs.Stat(c.SourceDir)
+	info, err := c.fs.Stat(c.SourceDir)
 	switch {
 	case err == nil && info.IsDir():
-		private, err := chezmoi.IsPrivate(fs, c.SourceDir)
+		private, err := chezmoi.IsPrivate(c.fs, c.SourceDir)
 		if err != nil {
 			return err
 		}
 		if !private {
-			if err := mutator.Chmod(c.SourceDir, 0700&^os.FileMode(c.Umask)); err != nil {
+			if err := c.mutator.Chmod(c.SourceDir, 0700&^os.FileMode(c.Umask)); err != nil {
 				return err
 			}
 		}
 		return nil
 	case os.IsNotExist(err):
-		return mutator.Mkdir(c.SourceDir, 0700&^os.FileMode(c.Umask))
+		return c.mutator.Mkdir(c.SourceDir, 0700&^os.FileMode(c.Umask))
 	case err == nil:
 		return fmt.Errorf("%s: not a directory", c.SourceDir)
 	default:
@@ -265,23 +268,6 @@ func (c *Config) ensureSourceDirectory(fs chezmoi.PrivacyStater, mutator chezmoi
 	}
 }
 
-func (c *Config) execEditor(fs vfs.FS, argv ...string) error {
-	return c.exec(fs, append([]string{c.getEditor()}, argv...))
-}
-
-func (c *Config) getDefaultMutator(fs vfs.FS) chezmoi.Mutator {
-	var mutator chezmoi.Mutator
-	if c.DryRun {
-		mutator = chezmoi.NullMutator{}
-	} else {
-		mutator = chezmoi.NewFSMutator(fs)
-	}
-	if c.Verbose {
-		mutator = chezmoi.NewLoggingMutator(c.Stdout(), mutator, c.colored)
-	}
-	return mutator
-}
-
 func (c *Config) getEditor() string {
 	if editor := os.Getenv("VISUAL"); editor != "" {
 		return editor
@@ -292,14 +278,14 @@ func (c *Config) getEditor() string {
 	return "vi"
 }
 
-func (c *Config) getEntries(fs vfs.Stater, ts *chezmoi.TargetState, args []string) ([]chezmoi.Entry, error) {
+func (c *Config) getEntries(ts *chezmoi.TargetState, args []string) ([]chezmoi.Entry, error) {
 	entries := []chezmoi.Entry{}
 	for _, arg := range args {
 		targetPath, err := filepath.Abs(arg)
 		if err != nil {
 			return nil, err
 		}
-		entry, err := ts.Get(fs, targetPath)
+		entry, err := ts.Get(c.fs, targetPath)
 		if err != nil {
 			return nil, err
 		}
@@ -311,7 +297,7 @@ func (c *Config) getEntries(fs vfs.Stater, ts *chezmoi.TargetState, args []strin
 	return entries, nil
 }
 
-func (c *Config) getPersistentState(fs vfs.FS, options *bolt.Options) (chezmoi.PersistentState, error) {
+func (c *Config) getPersistentState(options *bolt.Options) (chezmoi.PersistentState, error) {
 	persistentStateFile := c.getPersistentStateFile()
 	if c.DryRun {
 		if options == nil {
@@ -319,7 +305,7 @@ func (c *Config) getPersistentState(fs vfs.FS, options *bolt.Options) (chezmoi.P
 		}
 		options.ReadOnly = true
 	}
-	return chezmoi.NewBoltPersistentState(fs, persistentStateFile, options)
+	return chezmoi.NewBoltPersistentState(c.fs, persistentStateFile, options)
 }
 
 func (c *Config) getPersistentStateFile() string {
@@ -335,9 +321,9 @@ func (c *Config) getPersistentStateFile() string {
 	return filepath.Join(filepath.Dir(getDefaultConfigFile(c.bds)), "chezmoistate.boltdb")
 }
 
-func (c *Config) getTargetState(fs vfs.FS, populateOptions *chezmoi.PopulateOptions) (*chezmoi.TargetState, error) {
-	fs = vfs.NewReadOnlyFS(fs)
-	defaultData, err := getDefaultData(fs)
+func (c *Config) getTargetState(populateOptions *chezmoi.PopulateOptions) (*chezmoi.TargetState, error) {
+	fs := vfs.NewReadOnlyFS(c.fs)
+	defaultData, err := getDefaultData(c.fs)
 	if err != nil {
 		return nil, err
 	}
@@ -379,7 +365,7 @@ func (c *Config) getVCS() (VCS, error) {
 	return vcs, nil
 }
 
-func (c *Config) output(fs vfs.FS, dir, name string, argv ...string) ([]byte, error) {
+func (c *Config) output(dir, name string, argv ...string) ([]byte, error) {
 	if c.Verbose {
 		if dir == "" {
 			fmt.Printf("%s %s\n", name, strings.Join(argv, " "))
@@ -387,18 +373,15 @@ func (c *Config) output(fs vfs.FS, dir, name string, argv ...string) ([]byte, er
 			fmt.Printf("( cd %s && %s %s )\n", dir, name, strings.Join(argv, " "))
 		}
 	}
-	if c.DryRun {
-		return nil, nil
-	}
 	cmd := exec.Command(name, argv...)
 	if dir != "" {
 		var err error
-		cmd.Dir, err = fs.RawPath(dir)
+		cmd.Dir, err = c.fs.RawPath(dir)
 		if err != nil {
 			return nil, err
 		}
 	}
-	return cmd.Output()
+	return c.mutator.IdempotentCmdOutput(cmd)
 }
 
 //nolint:unparam
@@ -421,7 +404,7 @@ func (c *Config) prompt(s, choices string) (byte, error) {
 }
 
 // run runs name argv... in dir.
-func (c *Config) run(fs vfs.FS, dir, name string, argv ...string) error {
+func (c *Config) run(dir, name string, argv ...string) error {
 	if c.Verbose {
 		if dir == "" {
 			fmt.Printf("%s %s\n", name, strings.Join(argv, " "))
@@ -435,7 +418,7 @@ func (c *Config) run(fs vfs.FS, dir, name string, argv ...string) error {
 	cmd := exec.Command(name, argv...)
 	if dir != "" {
 		var err error
-		cmd.Dir, err = fs.RawPath(dir)
+		cmd.Dir, err = c.fs.RawPath(dir)
 		if err != nil {
 			return err
 		}
@@ -443,11 +426,14 @@ func (c *Config) run(fs vfs.FS, dir, name string, argv ...string) error {
 	cmd.Stdin = c.Stdin()
 	cmd.Stdout = c.Stdout()
 	cmd.Stderr = c.Stdout()
+	if c.Debug {
+		return chezmoi.Debugf("run %s", []interface{}{cmd}, cmd.Run)
+	}
 	return cmd.Run()
 }
 
-func (c *Config) runEditor(fs vfs.FS, argv ...string) error {
-	return c.run(fs, "", c.getEditor(), argv...)
+func (c *Config) runEditor(argv ...string) error {
+	return c.run("", c.getEditor(), argv...)
 }
 
 func (c *Config) warn(s string) {
@@ -537,12 +523,6 @@ func isWellKnownAbbreviation(word string) bool {
 	return ok
 }
 
-func makeRunE(runCmd func(vfs.FS, []string) error) func(*cobra.Command, []string) error {
-	return func(cmd *cobra.Command, args []string) error {
-		return runCmd(vfs.OSFS, args)
-	}
-}
-
 func printErrorAndExit(err error) {
 	fmt.Printf("chezmoi: %v\n", err)
 	os.Exit(1)
diff --git a/cmd/config_posix.go b/cmd/config_posix.go
deleted file mode 100644
index 2c2b6d3e9..000000000
--- a/cmd/config_posix.go
+++ /dev/null
@@ -1,28 +0,0 @@
-// +build !windows
-
-package cmd
-
-import (
-	"fmt"
-	"os"
-	"os/exec"
-	"strings"
-	"syscall"
-
-	vfs "github.com/twpayne/go-vfs"
-)
-
-func (c *Config) exec(fs vfs.FS, argv []string) error {
-	path, err := exec.LookPath(argv[0])
-	if err != nil {
-		return err
-	}
-	if c.Verbose {
-		fmt.Printf("exec %s\n", strings.Join(argv, " "))
-	}
-	if c.DryRun {
-		return nil
-	}
-
-	return syscall.Exec(path, argv, os.Environ())
-}
diff --git a/cmd/config_windows.go b/cmd/config_windows.go
deleted file mode 100644
index bae9739ce..000000000
--- a/cmd/config_windows.go
+++ /dev/null
@@ -1,10 +0,0 @@
-// +build windows
-
-package cmd
-
-import vfs "github.com/twpayne/go-vfs"
-
-// exec, on windows, calls run since legit exec doesn't really exist.
-func (c *Config) exec(fs vfs.FS, argv []string) error {
-	return c.run(fs, "", argv[0], argv[1:]...)
-}
diff --git a/cmd/data.go b/cmd/data.go
index 08dd090ec..c09aae18f 100644
--- a/cmd/data.go
+++ b/cmd/data.go
@@ -5,7 +5,6 @@ import (
 	"strings"
 
 	"github.com/spf13/cobra"
-	vfs "github.com/twpayne/go-vfs"
 )
 
 type dataCmdConfig struct {
@@ -19,7 +18,7 @@ var dataCmd = &cobra.Command{
 	Long:    mustGetLongHelp("data"),
 	Example: getExample("data"),
 	PreRunE: config.ensureNoError,
-	RunE:    makeRunE(config.runDataCmd),
+	RunE:    config.runDataCmd,
 }
 
 func init() {
@@ -29,12 +28,12 @@ func init() {
 	persistentFlags.StringVarP(&config.data.format, "format", "f", "json", "format (JSON, TOML, or YAML)")
 }
 
-func (c *Config) runDataCmd(fs vfs.FS, args []string) error {
+func (c *Config) runDataCmd(cmd *cobra.Command, args []string) error {
 	format, ok := formatMap[strings.ToLower(c.data.format)]
 	if !ok {
 		return fmt.Errorf("%s: unknown format", c.data.format)
 	}
-	ts, err := c.getTargetState(fs, nil)
+	ts, err := c.getTargetState(nil)
 	if err != nil {
 		return err
 	}
diff --git a/cmd/diff.go b/cmd/diff.go
index eb62679c9..a2d77f61f 100644
--- a/cmd/diff.go
+++ b/cmd/diff.go
@@ -3,7 +3,6 @@ package cmd
 import (
 	"github.com/spf13/cobra"
 	"github.com/twpayne/chezmoi/internal/chezmoi"
-	vfs "github.com/twpayne/go-vfs"
 	bolt "go.etcd.io/bbolt"
 )
 
@@ -13,18 +12,18 @@ var diffCmd = &cobra.Command{
 	Long:    mustGetLongHelp("diff"),
 	Example: getExample("diff"),
 	PreRunE: config.ensureNoError,
-	RunE:    makeRunE(config.runDiffCmd),
+	RunE:    config.runDiffCmd,
 }
 
 func init() {
 	rootCmd.AddCommand(diffCmd)
 }
 
-func (c *Config) runDiffCmd(fs vfs.FS, args []string) error {
+func (c *Config) runDiffCmd(cmd *cobra.Command, args []string) error {
 	c.DryRun = true
-	mutator := chezmoi.NewLoggingMutator(c.Stdout(), chezmoi.NullMutator{}, c.colored)
+	c.mutator = chezmoi.NewVerboseMutator(c.Stdout(), chezmoi.NullMutator{}, c.colored)
 
-	persistentState, err := c.getPersistentState(fs, &bolt.Options{
+	persistentState, err := c.getPersistentState(&bolt.Options{
 		ReadOnly: true,
 	})
 	if err != nil {
@@ -32,5 +31,5 @@ func (c *Config) runDiffCmd(fs vfs.FS, args []string) error {
 	}
 	defer persistentState.Close()
 
-	return c.applyArgs(fs, args, mutator, persistentState)
+	return c.applyArgs(args, persistentState)
 }
diff --git a/cmd/docs.go b/cmd/docs.go
index 5034d3e70..03798e0be 100644
--- a/cmd/docs.go
+++ b/cmd/docs.go
@@ -8,7 +8,6 @@ import (
 	"strings"
 
 	"github.com/spf13/cobra"
-	vfs "github.com/twpayne/go-vfs"
 )
 
 var docsCmd = &cobra.Command{
@@ -17,14 +16,14 @@ var docsCmd = &cobra.Command{
 	Short:   "Print documentation",
 	Long:    mustGetLongHelp("docs"),
 	Example: getExample("docs"),
-	RunE:    makeRunE(config.runDocsCmd),
+	RunE:    config.runDocsCmd,
 }
 
 func init() {
 	rootCmd.AddCommand(docsCmd)
 }
 
-func (c *Config) runDocsCmd(fs vfs.FS, args []string) error {
+func (c *Config) runDocsCmd(cmd *cobra.Command, args []string) error {
 	filename := "REFERENCE.md"
 	if len(args) > 0 {
 		pattern := args[0]
diff --git a/cmd/doctor.go b/cmd/doctor.go
index ffc8e9022..1d9db064c 100644
--- a/cmd/doctor.go
+++ b/cmd/doctor.go
@@ -11,7 +11,6 @@ import (
 	"github.com/coreos/go-semver/semver"
 	"github.com/spf13/cobra"
 	shell "github.com/twpayne/go-shell"
-	vfs "github.com/twpayne/go-vfs"
 )
 
 var doctorCmd = &cobra.Command{
@@ -20,7 +19,7 @@ var doctorCmd = &cobra.Command{
 	Short:   "Check your system for potential problems",
 	Example: getExample("doctor"),
 	Long:    mustGetLongHelp("doctor"),
-	RunE:    makeRunE(config.runDoctorCmd),
+	RunE:    config.runDoctorCmd,
 }
 
 const (
@@ -89,7 +88,7 @@ func init() {
 	rootCmd.AddCommand(doctorCmd)
 }
 
-func (c *Config) runDoctorCmd(fs vfs.FS, args []string) error {
+func (c *Config) runDoctorCmd(cmd *cobra.Command, args []string) error {
 	shell, _ := shell.CurrentUserShell()
 
 	var vcsCommandCheck doctorCheck
diff --git a/cmd/dump.go b/cmd/dump.go
index db2281f2f..037f6f399 100644
--- a/cmd/dump.go
+++ b/cmd/dump.go
@@ -6,7 +6,6 @@ import (
 	"strings"
 
 	"github.com/spf13/cobra"
-	vfs "github.com/twpayne/go-vfs"
 )
 
 type dumpCmdConfig struct {
@@ -20,7 +19,7 @@ var dumpCmd = &cobra.Command{
 	Long:    mustGetLongHelp("dump"),
 	Example: getExample("dump"),
 	PreRunE: config.ensureNoError,
-	RunE:    makeRunE(config.runDumpCmd),
+	RunE:    config.runDumpCmd,
 }
 
 func init() {
@@ -31,12 +30,12 @@ func init() {
 	persistentFlags.BoolVarP(&config.dump.recursive, "recursive", "r", true, "recursive")
 }
 
-func (c *Config) runDumpCmd(fs vfs.FS, args []string) error {
+func (c *Config) runDumpCmd(cmd *cobra.Command, args []string) error {
 	format, ok := formatMap[strings.ToLower(c.dump.format)]
 	if !ok {
 		return fmt.Errorf("%s: unknown format", c.dump.format)
 	}
-	ts, err := c.getTargetState(fs, nil)
+	ts, err := c.getTargetState(nil)
 	if err != nil {
 		return err
 	}
@@ -47,7 +46,7 @@ func (c *Config) runDumpCmd(fs vfs.FS, args []string) error {
 			return err
 		}
 	} else {
-		entries, err := c.getEntries(fs, ts, args)
+		entries, err := c.getEntries(ts, args)
 		if err != nil {
 			return err
 		}
diff --git a/cmd/edit.go b/cmd/edit.go
index 096c88ad1..10769e9e9 100644
--- a/cmd/edit.go
+++ b/cmd/edit.go
@@ -18,8 +18,8 @@ var editCmd = &cobra.Command{
 	Long:     mustGetLongHelp("edit"),
 	Example:  getExample("edit"),
 	PreRunE:  config.ensureNoError,
-	RunE:     makeRunE(config.runEditCmd),
-	PostRunE: makeRunE(config.autoCommitAndAutoPush),
+	RunE:     config.runEditCmd,
+	PostRunE: config.autoCommitAndAutoPush,
 }
 
 type editCmdConfig struct {
@@ -44,7 +44,7 @@ type encryptedFile struct {
 	plaintextPath  string
 }
 
-func (c *Config) runEditCmd(fs vfs.FS, args []string) error {
+func (c *Config) runEditCmd(cmd *cobra.Command, args []string) error {
 	if len(args) == 0 {
 		if c.edit.apply {
 			c.warn("--apply is currently ignored when edit is run with no arguments")
@@ -55,21 +55,21 @@ func (c *Config) runEditCmd(fs vfs.FS, args []string) error {
 		if c.edit.prompt {
 			c.warn("--prompt is currently ignored when edit is run with no arguments")
 		}
-		return c.execEditor(fs, c.SourceDir)
+		return c.run("", c.getEditor(), c.SourceDir)
 	}
 
 	if c.edit.prompt {
 		c.edit.diff = true
 	}
 
-	ts, err := c.getTargetState(fs, &chezmoi.PopulateOptions{
+	ts, err := c.getTargetState(&chezmoi.PopulateOptions{
 		ExecuteTemplates: false,
 	})
 	if err != nil {
 		return err
 	}
 
-	entries, err := c.getEntries(fs, ts, args)
+	entries, err := c.getEntries(ts, args)
 	if err != nil {
 		return err
 	}
@@ -121,7 +121,7 @@ func (c *Config) runEditCmd(fs vfs.FS, args []string) error {
 		}
 	}
 
-	if err := c.runEditor(fs, argv...); err != nil {
+	if err := c.runEditor(argv...); err != nil {
 		return err
 	}
 
@@ -141,18 +141,17 @@ func (c *Config) runEditCmd(fs vfs.FS, args []string) error {
 	}
 
 	// Recompute the target state and entries after editing.
-	ts, err = c.getTargetState(fs, nil)
+	ts, err = c.getTargetState(nil)
 	if err != nil {
 		return err
 	}
 
-	entries, err = c.getEntries(fs, ts, args)
+	entries, err = c.getEntries(ts, args)
 	if err != nil {
 		return err
 	}
 
-	readOnlyFS := vfs.NewReadOnlyFS(fs)
-	applyMutator := c.getDefaultMutator(fs)
+	readOnlyFS := vfs.NewReadOnlyFS(c.fs)
 	applyOptions := chezmoi.ApplyOptions{
 		DestDir:           ts.DestDir,
 		DryRun:            c.DryRun,
@@ -166,7 +165,7 @@ func (c *Config) runEditCmd(fs vfs.FS, args []string) error {
 		anyMutator := chezmoi.NewAnyMutator(chezmoi.NullMutator{})
 		var mutator chezmoi.Mutator = anyMutator
 		if c.edit.diff {
-			mutator = chezmoi.NewLoggingMutator(c.Stdout(), mutator, c.colored)
+			mutator = chezmoi.NewVerboseMutator(c.Stdout(), mutator, c.colored)
 		}
 		if err := entry.Apply(readOnlyFS, mutator, c.Follow, &applyOptions); err != nil {
 			return err
@@ -187,7 +186,7 @@ func (c *Config) runEditCmd(fs vfs.FS, args []string) error {
 					c.edit.prompt = false
 				}
 			}
-			if err := entry.Apply(readOnlyFS, applyMutator, c.Follow, &applyOptions); err != nil {
+			if err := entry.Apply(readOnlyFS, c.mutator, c.Follow, &applyOptions); err != nil {
 				return err
 			}
 		}
diff --git a/cmd/editconfig.go b/cmd/editconfig.go
index d4cc52a49..4dcce21c8 100644
--- a/cmd/editconfig.go
+++ b/cmd/editconfig.go
@@ -17,29 +17,28 @@ var editConfigCommand = &cobra.Command{
 	Short:   "Edit the configuration file",
 	Long:    mustGetLongHelp("edit-config"),
 	Example: getExample("edit-config"),
-	RunE:    makeRunE(config.runEditConfigCmd),
+	RunE:    config.runEditConfigCmd,
 }
 
 func init() {
 	rootCmd.AddCommand(editConfigCommand)
 }
 
-func (c *Config) runEditConfigCmd(fs vfs.FS, args []string) error {
+func (c *Config) runEditConfigCmd(cmd *cobra.Command, args []string) error {
 	dir := filepath.Dir(c.configFile)
-	if _, err := fs.Stat(dir); os.IsNotExist(err) {
-		mutator := c.getDefaultMutator(fs)
-		if err := vfs.MkdirAll(mutator, dir, 0777); err != nil {
+	if _, err := c.fs.Stat(dir); os.IsNotExist(err) {
+		if err := vfs.MkdirAll(c.mutator, dir, 0777); err != nil {
 			return err
 		}
 	}
 
-	if err := c.runEditor(fs, c.configFile); err != nil {
+	if err := c.runEditor(c.configFile); err != nil {
 		return err
 	}
 
 	// Warn the user of any errors reading the config file.
 	v := viper.New()
-	v.SetFs(vfsafero.NewAferoFS(fs))
+	v.SetFs(vfsafero.NewAferoFS(c.fs))
 	v.SetConfigFile(c.configFile)
 	err := v.ReadInConfig()
 	if err == nil {
diff --git a/cmd/forget.go b/cmd/forget.go
index da9363be8..689cfe544 100644
--- a/cmd/forget.go
+++ b/cmd/forget.go
@@ -4,7 +4,6 @@ import (
 	"path/filepath"
 
 	"github.com/spf13/cobra"
-	vfs "github.com/twpayne/go-vfs"
 )
 
 var forgetCmd = &cobra.Command{
@@ -15,26 +14,25 @@ var forgetCmd = &cobra.Command{
 	Long:     mustGetLongHelp("forget"),
 	Example:  getExample("forget"),
 	PreRunE:  config.ensureNoError,
-	RunE:     makeRunE(config.runForgetCmd),
-	PostRunE: makeRunE(config.autoCommitAndAutoPush),
+	RunE:     config.runForgetCmd,
+	PostRunE: config.autoCommitAndAutoPush,
 }
 
 func init() {
 	rootCmd.AddCommand(forgetCmd)
 }
 
-func (c *Config) runForgetCmd(fs vfs.FS, args []string) error {
-	ts, err := c.getTargetState(fs, nil)
+func (c *Config) runForgetCmd(cmd *cobra.Command, args []string) error {
+	ts, err := c.getTargetState(nil)
 	if err != nil {
 		return err
 	}
-	entries, err := c.getEntries(fs, ts, args)
+	entries, err := c.getEntries(ts, args)
 	if err != nil {
 		return err
 	}
-	mutator := c.getDefaultMutator(fs)
 	for _, entry := range entries {
-		if err := mutator.RemoveAll(filepath.Join(c.SourceDir, entry.SourceName())); err != nil {
+		if err := c.mutator.RemoveAll(filepath.Join(c.SourceDir, entry.SourceName())); err != nil {
 			return err
 		}
 	}
diff --git a/cmd/import.go b/cmd/import.go
index b15093f44..79a291f81 100644
--- a/cmd/import.go
+++ b/cmd/import.go
@@ -12,7 +12,6 @@ import (
 
 	"github.com/spf13/cobra"
 	"github.com/twpayne/chezmoi/internal/chezmoi"
-	vfs "github.com/twpayne/go-vfs"
 )
 
 var _importCmd = &cobra.Command{
@@ -22,7 +21,7 @@ var _importCmd = &cobra.Command{
 	Long:    mustGetLongHelp("import"),
 	Example: getExample("import"),
 	PreRunE: config.ensureNoError,
-	RunE:    makeRunE(config.runImportCmd),
+	RunE:    config.runImportCmd,
 }
 
 type importCmdConfig struct {
@@ -40,8 +39,8 @@ func init() {
 	persistentFlags.BoolVarP(&config._import.removeDestination, "remove-destination", "r", false, "remove destination before import")
 }
 
-func (c *Config) runImportCmd(fs vfs.FS, args []string) error {
-	ts, err := c.getTargetState(fs, nil)
+func (c *Config) runImportCmd(cmd *cobra.Command, args []string) error {
+	ts, err := c.getTargetState(nil)
 	if err != nil {
 		return err
 	}
@@ -50,7 +49,7 @@ func (c *Config) runImportCmd(fs vfs.FS, args []string) error {
 		r = c.Stdin()
 	} else {
 		arg := args[0]
-		f, err := fs.Open(arg)
+		f, err := c.fs.Open(arg)
 		if err != nil {
 			return err
 		}
@@ -69,12 +68,11 @@ func (c *Config) runImportCmd(fs vfs.FS, args []string) error {
 			return fmt.Errorf("%s: unknown format", arg)
 		}
 	}
-	mutator := c.getDefaultMutator(fs)
 	if c._import.removeDestination {
-		entry, err := ts.Get(fs, c._import.importTAROptions.DestinationDir)
+		entry, err := ts.Get(c.fs, c._import.importTAROptions.DestinationDir)
 		switch {
 		case err == nil:
-			if err := mutator.RemoveAll(filepath.Join(c.SourceDir, entry.SourceName())); err != nil {
+			if err := c.mutator.RemoveAll(filepath.Join(c.SourceDir, entry.SourceName())); err != nil {
 				return err
 			}
 		case os.IsNotExist(err):
@@ -82,5 +80,5 @@ func (c *Config) runImportCmd(fs vfs.FS, args []string) error {
 			return err
 		}
 	}
-	return ts.ImportTAR(tar.NewReader(r), c._import.importTAROptions, mutator)
+	return ts.ImportTAR(tar.NewReader(r), c._import.importTAROptions, c.mutator)
 }
diff --git a/cmd/init.go b/cmd/init.go
index 092951f7a..a797aed7c 100644
--- a/cmd/init.go
+++ b/cmd/init.go
@@ -22,7 +22,7 @@ var initCmd = &cobra.Command{
 	Long:    mustGetLongHelp("init"),
 	Example: getExample("init"),
 	PreRunE: config.ensureNoError,
-	RunE:    makeRunE(config.runInitCmd),
+	RunE:    config.runInitCmd,
 }
 
 type initCmdConfig struct {
@@ -36,19 +36,17 @@ func init() {
 	persistentFlags.BoolVar(&config.init.apply, "apply", false, "update destination directory")
 }
 
-func (c *Config) runInitCmd(fs vfs.FS, args []string) error {
+func (c *Config) runInitCmd(cmd *cobra.Command, args []string) error {
 	vcs, err := c.getVCS()
 	if err != nil {
 		return err
 	}
 
-	mutator := c.getDefaultMutator(fs)
-
-	if err := c.ensureSourceDirectory(fs, mutator); err != nil {
+	if err := c.ensureSourceDirectory(); err != nil {
 		return err
 	}
 
-	rawSourceDir, err := fs.RawPath(c.SourceDir)
+	rawSourceDir, err := c.fs.RawPath(c.SourceDir)
 	if err != nil {
 		return err
 	}
@@ -68,7 +66,7 @@ func (c *Config) runInitCmd(fs vfs.FS, args []string) error {
 		} else {
 			initArgs = vcs.InitArgs()
 		}
-		if err := c.run(fs, c.SourceDir, c.SourceVCS.Command, initArgs...); err != nil {
+		if err := c.run(c.SourceDir, c.SourceVCS.Command, initArgs...); err != nil {
 			return err
 		}
 	case 1: // clone
@@ -76,17 +74,17 @@ func (c *Config) runInitCmd(fs vfs.FS, args []string) error {
 		if cloneArgs == nil {
 			return fmt.Errorf("%s: cloning not supported", c.SourceVCS.Command)
 		}
-		if err := c.run(fs, "", c.SourceVCS.Command, cloneArgs...); err != nil {
+		if err := c.run("", c.SourceVCS.Command, cloneArgs...); err != nil {
 			return err
 		}
 		// FIXME this should be part of VCS
 		if filepath.Base(c.SourceVCS.Command) == "git" {
-			if _, err := fs.Stat(filepath.Join(c.SourceDir, ".gitmodules")); err == nil {
+			if _, err := c.fs.Stat(filepath.Join(c.SourceDir, ".gitmodules")); err == nil {
 				for _, args := range [][]string{
 					{"submodule", "init"},
 					{"submodule", "update"},
 				} {
-					if err := c.run(fs, c.SourceDir, c.SourceVCS.Command, args...); err != nil {
+					if err := c.run(c.SourceDir, c.SourceVCS.Command, args...); err != nil {
 						return err
 					}
 				}
@@ -94,16 +92,16 @@ func (c *Config) runInitCmd(fs vfs.FS, args []string) error {
 		}
 	}
 
-	if err := c.createConfigFile(fs, mutator); err != nil {
+	if err := c.createConfigFile(); err != nil {
 		return err
 	}
 
 	if c.init.apply {
-		persistentState, err := c.getPersistentState(fs, nil)
+		persistentState, err := c.getPersistentState(nil)
 		if err != nil {
 			return err
 		}
-		if err := c.applyArgs(fs, nil, mutator, persistentState); err != nil {
+		if err := c.applyArgs(nil, persistentState); err != nil {
 			return err
 		}
 	}
@@ -111,8 +109,8 @@ func (c *Config) runInitCmd(fs vfs.FS, args []string) error {
 	return nil
 }
 
-func (c *Config) createConfigFile(fs vfs.FS, mutator chezmoi.Mutator) error {
-	filename, ext, data, err := c.findConfigTemplate(fs)
+func (c *Config) createConfigFile() error {
+	filename, ext, data, err := c.findConfigTemplate()
 	if err != nil {
 		return err
 	}
@@ -129,7 +127,7 @@ func (c *Config) createConfigFile(fs vfs.FS, mutator chezmoi.Mutator) error {
 		return err
 	}
 
-	defaultData, err := getDefaultData(fs)
+	defaultData, err := getDefaultData(c.fs)
 	if err != nil {
 		return err
 	}
@@ -142,12 +140,12 @@ func (c *Config) createConfigFile(fs vfs.FS, mutator chezmoi.Mutator) error {
 	}
 
 	configDir := filepath.Join(c.bds.ConfigHome, "chezmoi")
-	if err := vfs.MkdirAll(mutator, configDir, 0777&^os.FileMode(c.Umask)); err != nil {
+	if err := vfs.MkdirAll(c.mutator, configDir, 0777&^os.FileMode(c.Umask)); err != nil {
 		return err
 	}
 
 	configPath := filepath.Join(configDir, filename)
-	if err := mutator.WriteFile(configPath, contents.Bytes(), 0600&^os.FileMode(c.Umask), nil); err != nil {
+	if err := c.mutator.WriteFile(configPath, contents.Bytes(), 0600&^os.FileMode(c.Umask), nil); err != nil {
 		return err
 	}
 
@@ -158,9 +156,9 @@ func (c *Config) createConfigFile(fs vfs.FS, mutator chezmoi.Mutator) error {
 	return viper.Unmarshal(c)
 }
 
-func (c *Config) findConfigTemplate(fs vfs.FS) (string, string, string, error) {
+func (c *Config) findConfigTemplate() (string, string, string, error) {
 	for _, ext := range viper.SupportedExts {
-		contents, err := fs.ReadFile(filepath.Join(c.SourceDir, ".chezmoi."+ext+chezmoi.TemplateSuffix))
+		contents, err := c.fs.ReadFile(filepath.Join(c.SourceDir, ".chezmoi."+ext+chezmoi.TemplateSuffix))
 		switch {
 		case os.IsNotExist(err):
 			continue
diff --git a/cmd/merge.go b/cmd/merge.go
index 29c6ddd57..e80c1edc6 100644
--- a/cmd/merge.go
+++ b/cmd/merge.go
@@ -8,7 +8,6 @@ import (
 
 	"github.com/spf13/cobra"
 	"github.com/twpayne/chezmoi/internal/chezmoi"
-	vfs "github.com/twpayne/go-vfs"
 )
 
 var mergeCmd = &cobra.Command{
@@ -18,7 +17,7 @@ var mergeCmd = &cobra.Command{
 	Long:    mustGetLongHelp("merge"),
 	Example: getExample("merge"),
 	PreRunE: config.ensureNoError,
-	RunE:    makeRunE(config.runMergeCmd),
+	RunE:    config.runMergeCmd,
 }
 
 type mergeConfig struct {
@@ -30,13 +29,13 @@ func init() {
 	rootCmd.AddCommand(mergeCmd)
 }
 
-func (c *Config) runMergeCmd(fs vfs.FS, args []string) error {
-	ts, err := c.getTargetState(fs, nil)
+func (c *Config) runMergeCmd(cmd *cobra.Command, args []string) error {
+	ts, err := c.getTargetState(nil)
 	if err != nil {
 		return err
 	}
 
-	entries, err := c.getEntries(fs, ts, args)
+	entries, err := c.getEntries(ts, args)
 	if err != nil {
 		return err
 	}
@@ -51,7 +50,7 @@ func (c *Config) runMergeCmd(fs vfs.FS, args []string) error {
 	defer os.RemoveAll(tempDir)
 
 	for i, entry := range entries {
-		if err := c.runMergeCommand(fs, args[i], entry, tempDir); err != nil {
+		if err := c.runMergeCommand(args[i], entry, tempDir); err != nil {
 			return err
 		}
 	}
@@ -59,7 +58,7 @@ func (c *Config) runMergeCmd(fs vfs.FS, args []string) error {
 	return nil
 }
 
-func (c *Config) runMergeCommand(fs vfs.FS, arg string, entry chezmoi.Entry, tempDir string) error {
+func (c *Config) runMergeCommand(arg string, entry chezmoi.Entry, tempDir string) error {
 	file, ok := entry.(*chezmoi.File)
 	if !ok {
 		return fmt.Errorf("%s: not a file", arg)
@@ -87,7 +86,7 @@ func (c *Config) runMergeCommand(fs vfs.FS, arg string, entry chezmoi.Entry, tem
 		args = append(args, targetStatePath)
 	}
 
-	if err := c.run(fs, "", c.Merge.Command, args...); err != nil {
+	if err := c.run("", c.Merge.Command, args...); err != nil {
 		return fmt.Errorf("%s: %w", arg, err)
 	}
 
diff --git a/cmd/remove.go b/cmd/remove.go
index 36648c040..a20db2c17 100644
--- a/cmd/remove.go
+++ b/cmd/remove.go
@@ -6,7 +6,6 @@ import (
 	"path/filepath"
 
 	"github.com/spf13/cobra"
-	vfs "github.com/twpayne/go-vfs"
 )
 
 type removeCmdConfig struct {
@@ -21,8 +20,8 @@ var removeCmd = &cobra.Command{
 	Long:     mustGetLongHelp("remove"),
 	Example:  getExample("remove"),
 	PreRunE:  config.ensureNoError,
-	RunE:     makeRunE(config.runRemoveCmd),
-	PostRunE: makeRunE(config.autoCommitAndAutoPush),
+	RunE:     config.runRemoveCmd,
+	PostRunE: config.autoCommitAndAutoPush,
 }
 
 func init() {
@@ -32,16 +31,15 @@ func init() {
 	persistentFlags.BoolVarP(&config.remove.force, "force", "f", false, "remove without prompting")
 }
 
-func (c *Config) runRemoveCmd(fs vfs.FS, args []string) error {
-	ts, err := c.getTargetState(fs, nil)
+func (c *Config) runRemoveCmd(cmd *cobra.Command, args []string) error {
+	ts, err := c.getTargetState(nil)
 	if err != nil {
 		return err
 	}
-	entries, err := c.getEntries(fs, ts, args)
+	entries, err := c.getEntries(ts, args)
 	if err != nil {
 		return nil
 	}
-	mutator := c.getDefaultMutator(fs)
 	for _, entry := range entries {
 		destDirPath := filepath.Join(c.DestDir, entry.TargetName())
 		sourceDirPath := filepath.Join(c.SourceDir, entry.SourceName())
@@ -60,10 +58,10 @@ func (c *Config) runRemoveCmd(fs vfs.FS, args []string) error {
 				c.remove.force = true
 			}
 		}
-		if err := mutator.RemoveAll(destDirPath); err != nil && !os.IsNotExist(err) {
+		if err := c.mutator.RemoveAll(destDirPath); err != nil && !os.IsNotExist(err) {
 			return err
 		}
-		if err := mutator.RemoveAll(sourceDirPath); err != nil && !os.IsNotExist(err) {
+		if err := c.mutator.RemoveAll(sourceDirPath); err != nil && !os.IsNotExist(err) {
 			return err
 		}
 	}
diff --git a/cmd/root.go b/cmd/root.go
index 927d206f1..4633afdd8 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -44,7 +44,7 @@ var rootCmd = &cobra.Command{
 	Short:             "Manage your dotfiles across multiple machines, securely",
 	SilenceErrors:     true,
 	SilenceUsage:      true,
-	PersistentPreRunE: makeRunE(config.persistentPreRunRootE),
+	PersistentPreRunE: config.persistentPreRunRootE,
 }
 
 func init() {
@@ -93,6 +93,9 @@ func init() {
 	persistentFlags.StringVar(&config.Color, "color", "auto", "colorize diffs")
 	_ = viper.BindPFlag("color", persistentFlags.Lookup("color"))
 
+	persistentFlags.BoolVar(&config.Debug, "debug", false, "write debug logs")
+	_ = viper.BindPFlag("debug", persistentFlags.Lookup("debug"))
+
 	cobra.OnInitialize(func() {
 		_, err := os.Stat(config.configFile)
 		switch {
@@ -120,7 +123,7 @@ func Execute() {
 }
 
 //nolint:interfacer
-func (c *Config) persistentPreRunRootE(fs vfs.FS, args []string) error {
+func (c *Config) persistentPreRunRootE(cmd *cobra.Command, args []string) error {
 	switch c.Color {
 	case "on":
 		c.colored = true
@@ -136,14 +139,26 @@ func (c *Config) persistentPreRunRootE(fs vfs.FS, args []string) error {
 		return fmt.Errorf("invalid --color value: %s", c.Color)
 	}
 
-	info, err := fs.Stat(c.SourceDir)
+	c.fs = vfs.OSFS
+	c.mutator = chezmoi.NewFSMutator(config.fs)
+	if config.DryRun {
+		c.mutator = chezmoi.NullMutator{}
+	}
+	if config.Debug {
+		c.mutator = chezmoi.NewDebugMutator(c.mutator)
+	}
+	if config.Verbose {
+		c.mutator = chezmoi.NewVerboseMutator(c.Stdout(), c.mutator, c.colored)
+	}
+
+	info, err := c.fs.Stat(c.SourceDir)
 	switch {
 	case err == nil && !info.IsDir():
 		return fmt.Errorf("%s: not a directory", c.SourceDir)
 	case err == nil:
 		// FIXME renable private check on Windows
 		if runtime.GOOS != "windows" {
-			private, err := chezmoi.IsPrivate(fs, c.SourceDir)
+			private, err := chezmoi.IsPrivate(c.fs, c.SourceDir)
 			if err != nil {
 				return err
 			}
diff --git a/cmd/secretbitwarden.go b/cmd/secretbitwarden.go
index d704ff92a..47d05a604 100644
--- a/cmd/secretbitwarden.go
+++ b/cmd/secretbitwarden.go
@@ -8,14 +8,13 @@ import (
 	"strings"
 
 	"github.com/spf13/cobra"
-	vfs "github.com/twpayne/go-vfs"
 )
 
 var bitwardenCmd = &cobra.Command{
 	Use:     "bitwarden [args...]",
 	Short:   "Execute the Bitwarden CLI (bw)",
 	PreRunE: config.ensureNoError,
-	RunE:    makeRunE(config.runBitwardenCmd),
+	RunE:    config.runBitwardenCmd,
 }
 
 type bitwardenCmdConfig struct {
@@ -31,8 +30,8 @@ func init() {
 	secretCmd.AddCommand(bitwardenCmd)
 }
 
-func (c *Config) runBitwardenCmd(fs vfs.FS, args []string) error {
-	return c.exec(fs, append([]string{c.Bitwarden.Command}, args...))
+func (c *Config) runBitwardenCmd(cmd *cobra.Command, args []string) error {
+	return c.run("", c.Bitwarden.Command, args...)
 }
 
 func (c *Config) bitwardenFunc(args ...string) interface{} {
@@ -48,7 +47,7 @@ func (c *Config) bitwardenFunc(args ...string) interface{} {
 	cmd := exec.Command(name, args...)
 	cmd.Stdin = os.Stdin
 	cmd.Stderr = os.Stderr
-	output, err := cmd.Output()
+	output, err := c.mutator.IdempotentCmdOutput(cmd)
 	if err != nil {
 		panic(fmt.Errorf("bitwarden: %s %s: %w\n%s", name, strings.Join(args, " "), err, output))
 	}
diff --git a/cmd/secretgeneric.go b/cmd/secretgeneric.go
index f896fd5e9..d784bffb1 100644
--- a/cmd/secretgeneric.go
+++ b/cmd/secretgeneric.go
@@ -9,14 +9,13 @@ import (
 	"strings"
 
 	"github.com/spf13/cobra"
-	vfs "github.com/twpayne/go-vfs"
 )
 
 var genericSecretCmd = &cobra.Command{
 	Use:     "generic [args...]",
 	Short:   "Execute a generic secret command",
 	PreRunE: config.ensureNoError,
-	RunE:    makeRunE(config.runGenericSecretCmd),
+	RunE:    config.runGenericSecretCmd,
 }
 
 type genericSecretCmdConfig struct {
@@ -35,8 +34,8 @@ func init() {
 	secretCmd.AddCommand(genericSecretCmd)
 }
 
-func (c *Config) runGenericSecretCmd(fs vfs.FS, args []string) error {
-	return c.exec(fs, append([]string{c.GenericSecret.Command}, args...))
+func (c *Config) runGenericSecretCmd(cmd *cobra.Command, args []string) error {
+	return c.run("", c.GenericSecret.Command, args...)
 }
 
 func (c *Config) secretFunc(args ...string) interface{} {
@@ -51,7 +50,7 @@ func (c *Config) secretFunc(args ...string) interface{} {
 	cmd := exec.Command(name, args...)
 	cmd.Stdin = os.Stdin
 	cmd.Stderr = os.Stderr
-	output, err := cmd.Output()
+	output, err := c.mutator.IdempotentCmdOutput(cmd)
 	if err != nil {
 		panic(fmt.Errorf("secret: %s %s: %w\n%s", name, strings.Join(args, " "), err, output))
 	}
@@ -72,7 +71,7 @@ func (c *Config) secretJSONFunc(args ...string) interface{} {
 	cmd := exec.Command(name, args...)
 	cmd.Stdin = os.Stdin
 	cmd.Stderr = os.Stderr
-	output, err := cmd.Output()
+	output, err := c.mutator.IdempotentCmdOutput(cmd)
 	if err != nil {
 		panic(fmt.Errorf("secretJSON: %s %s: %w\n%s", name, strings.Join(args, " "), err, output))
 	}
diff --git a/cmd/secretgopass.go b/cmd/secretgopass.go
index 3d71c239e..810e9c905 100644
--- a/cmd/secretgopass.go
+++ b/cmd/secretgopass.go
@@ -7,14 +7,13 @@ import (
 	"strings"
 
 	"github.com/spf13/cobra"
-	vfs "github.com/twpayne/go-vfs"
 )
 
 var gopassCmd = &cobra.Command{
 	Use:     "gopass [args...]",
 	Short:   "Execute the gopass CLI",
 	PreRunE: config.ensureNoError,
-	RunE:    makeRunE(config.runSecretGopassCmd),
+	RunE:    config.runSecretGopassCmd,
 }
 
 type gopassCmdConfig struct {
@@ -30,8 +29,8 @@ func init() {
 	config.addTemplateFunc("gopass", config.gopassFunc)
 }
 
-func (c *Config) runSecretGopassCmd(fs vfs.FS, args []string) error {
-	return c.exec(fs, append([]string{c.Pass.Command}, args...))
+func (c *Config) runSecretGopassCmd(cmd *cobra.Command, args []string) error {
+	return c.run("", c.Pass.Command, args...)
 }
 
 func (c *Config) gopassFunc(id string) string {
@@ -43,7 +42,8 @@ func (c *Config) gopassFunc(id string) string {
 	if c.Verbose {
 		fmt.Printf("%s %s\n", name, strings.Join(args, " "))
 	}
-	output, err := exec.Command(name, args...).Output()
+	cmd := exec.Command(name, args...)
+	output, err := c.mutator.IdempotentCmdOutput(cmd)
 	if err != nil {
 		panic(fmt.Errorf("gopass: %s %s: %w", name, strings.Join(args, " "), err))
 	}
diff --git a/cmd/secretkeepassxc.go b/cmd/secretkeepassxc.go
index 7692376d6..b4057a2b1 100644
--- a/cmd/secretkeepassxc.go
+++ b/cmd/secretkeepassxc.go
@@ -11,7 +11,6 @@ import (
 	"strings"
 
 	"github.com/spf13/cobra"
-	vfs "github.com/twpayne/go-vfs"
 	"golang.org/x/crypto/ssh/terminal"
 )
 
@@ -19,7 +18,7 @@ var keePassXCCmd = &cobra.Command{
 	Use:     "keepassxc [args...]",
 	Short:   "Execute the KeePassXC CLI (keepassxc-cli)",
 	PreRunE: config.ensureNoError,
-	RunE:    makeRunE(config.runKeePassXCCmd),
+	RunE:    config.runKeePassXCCmd,
 }
 
 type keePassXCCmdConfig struct {
@@ -48,8 +47,8 @@ func init() {
 	secretCmd.AddCommand(keePassXCCmd)
 }
 
-func (c *Config) runKeePassXCCmd(fs vfs.FS, args []string) error {
-	return c.exec(fs, append([]string{c.KeePassXC.Command}, args...))
+func (c *Config) runKeePassXCCmd(cmd *cobra.Command, args []string) error {
+	return c.run("", c.KeePassXC.Command, args...)
 }
 
 func (c *Config) keePassXCFunc(entry string) map[string]string {
@@ -118,7 +117,7 @@ func (c *Config) runKeePassXCCLICommand(name string, args []string) ([]byte, err
 	cmd := exec.Command(name, args...)
 	cmd.Stdin = bytes.NewBufferString(keePassXCPassword + "\n")
 	cmd.Stderr = c.Stderr()
-	return cmd.Output()
+	return c.mutator.IdempotentCmdOutput(cmd)
 }
 
 func parseKeyPassXCOutput(output []byte) (map[string]string, error) {
diff --git a/cmd/secretkeyringget.go b/cmd/secretkeyringget.go
index 8f0fa4609..041ab7d53 100644
--- a/cmd/secretkeyringget.go
+++ b/cmd/secretkeyringget.go
@@ -4,7 +4,6 @@ import (
 	"fmt"
 
 	"github.com/spf13/cobra"
-	vfs "github.com/twpayne/go-vfs"
 	keyring "github.com/zalando/go-keyring"
 )
 
@@ -13,14 +12,14 @@ var keyringGetCmd = &cobra.Command{
 	Args:    cobra.NoArgs,
 	Short:   "Get a password from keyring",
 	PreRunE: config.ensureNoError,
-	RunE:    makeRunE(config.runKeyringGetCmd),
+	RunE:    config.runKeyringGetCmd,
 }
 
 func init() {
 	keyringCmd.AddCommand(keyringGetCmd)
 }
 
-func (c *Config) runKeyringGetCmd(fs vfs.FS, args []string) error {
+func (c *Config) runKeyringGetCmd(cmd *cobra.Command, args []string) error {
 	password, err := keyring.Get(c.keyring.service, c.keyring.user)
 	if err != nil {
 		return err
diff --git a/cmd/secretkeyringset.go b/cmd/secretkeyringset.go
index b5610779b..2c5ca5a3d 100644
--- a/cmd/secretkeyringset.go
+++ b/cmd/secretkeyringset.go
@@ -5,7 +5,6 @@ import (
 	"os"
 
 	"github.com/spf13/cobra"
-	vfs "github.com/twpayne/go-vfs"
 	keyring "github.com/zalando/go-keyring"
 	"golang.org/x/crypto/ssh/terminal"
 )
@@ -15,7 +14,7 @@ var keyringSetCmd = &cobra.Command{
 	Args:    cobra.NoArgs,
 	Short:   "Set a password in keyring",
 	PreRunE: config.ensureNoError,
-	RunE:    makeRunE(config.runKeyringSetCmd),
+	RunE:    config.runKeyringSetCmd,
 }
 
 func init() {
@@ -25,7 +24,7 @@ func init() {
 	persistentFlags.StringVar(&config.keyring.password, "password", "", "password")
 }
 
-func (c *Config) runKeyringSetCmd(fs vfs.FS, args []string) error {
+func (c *Config) runKeyringSetCmd(cmd *cobra.Command, args []string) error {
 	passwordString := c.keyring.password
 	if passwordString == "" {
 		fmt.Print("Password: ")
diff --git a/cmd/secretlastpass.go b/cmd/secretlastpass.go
index 3ffc4a014..c4fd0d64d 100644
--- a/cmd/secretlastpass.go
+++ b/cmd/secretlastpass.go
@@ -14,14 +14,13 @@ import (
 
 	"github.com/coreos/go-semver/semver"
 	"github.com/spf13/cobra"
-	vfs "github.com/twpayne/go-vfs"
 )
 
 var lastpassCmd = &cobra.Command{
 	Use:     "lastpass [args...]",
 	Short:   "Execute the LastPass CLI (lpass)",
 	PreRunE: config.ensureNoError,
-	RunE:    makeRunE(config.runLastpassCmd),
+	RunE:    config.runLastpassCmd,
 }
 
 var (
@@ -48,8 +47,8 @@ func init() {
 	secretCmd.AddCommand(lastpassCmd)
 }
 
-func (c *Config) runLastpassCmd(fs vfs.FS, args []string) error {
-	return c.exec(fs, append([]string{c.Lastpass.Command}, args...))
+func (c *Config) runLastpassCmd(cmd *cobra.Command, args []string) error {
+	return c.run("", c.Lastpass.Command, args...)
 }
 
 func (c *Config) lastpassOutput(args ...string) ([]byte, error) {
@@ -60,7 +59,7 @@ func (c *Config) lastpassOutput(args ...string) ([]byte, error) {
 	cmd := exec.Command(name, args...)
 	cmd.Stdin = os.Stdin
 	cmd.Stderr = os.Stderr
-	output, err := cmd.Output()
+	output, err := c.mutator.IdempotentCmdOutput(cmd)
 	if err != nil {
 		return nil, err
 	}
diff --git a/cmd/secretonepassword.go b/cmd/secretonepassword.go
index 5ae78bb4c..b4650489d 100644
--- a/cmd/secretonepassword.go
+++ b/cmd/secretonepassword.go
@@ -8,14 +8,13 @@ import (
 	"strings"
 
 	"github.com/spf13/cobra"
-	vfs "github.com/twpayne/go-vfs"
 )
 
 var onepasswordCmd = &cobra.Command{
 	Use:     "onepassword [args...]",
 	Short:   "Execute the 1Password CLI (op)",
 	PreRunE: config.ensureNoError,
-	RunE:    makeRunE(config.runOnepasswordCmd),
+	RunE:    config.runOnepasswordCmd,
 }
 
 type onepasswordCmdConfig struct {
@@ -31,8 +30,8 @@ func init() {
 	secretCmd.AddCommand(onepasswordCmd)
 }
 
-func (c *Config) runOnepasswordCmd(fs vfs.FS, args []string) error {
-	return c.exec(fs, append([]string{c.Onepassword.Command}, args...))
+func (c *Config) runOnepasswordCmd(cmd *cobra.Command, args []string) error {
+	return c.run("", c.Onepassword.Command, args...)
 }
 
 func (c *Config) onepasswordFunc(item string) interface{} {
@@ -47,7 +46,7 @@ func (c *Config) onepasswordFunc(item string) interface{} {
 	cmd := exec.Command(name, args...)
 	cmd.Stdin = os.Stdin
 	cmd.Stderr = os.Stderr
-	output, err := cmd.Output()
+	output, err := c.mutator.IdempotentCmdOutput(cmd)
 	if err != nil {
 		panic(fmt.Errorf("onepassword: %s %s: %w\n%s", name, strings.Join(args, " "), err, output))
 	}
diff --git a/cmd/secretpass.go b/cmd/secretpass.go
index 9965bb156..6f8b683b2 100644
--- a/cmd/secretpass.go
+++ b/cmd/secretpass.go
@@ -7,14 +7,13 @@ import (
 	"strings"
 
 	"github.com/spf13/cobra"
-	vfs "github.com/twpayne/go-vfs"
 )
 
 var passCmd = &cobra.Command{
 	Use:     "pass [args...]",
 	Short:   "Execute the pass CLI",
 	PreRunE: config.ensureNoError,
-	RunE:    makeRunE(config.runSecretPassCmd),
+	RunE:    config.runSecretPassCmd,
 }
 
 type passCmdConfig struct {
@@ -30,8 +29,8 @@ func init() {
 	config.addTemplateFunc("pass", config.passFunc)
 }
 
-func (c *Config) runSecretPassCmd(fs vfs.FS, args []string) error {
-	return c.exec(fs, append([]string{c.Pass.Command}, args...))
+func (c *Config) runSecretPassCmd(cmd *cobra.Command, args []string) error {
+	return c.run("", c.Pass.Command, args...)
 }
 
 func (c *Config) passFunc(id string) string {
@@ -43,7 +42,8 @@ func (c *Config) passFunc(id string) string {
 	if c.Verbose {
 		fmt.Printf("%s %s\n", name, strings.Join(args, " "))
 	}
-	output, err := exec.Command(name, args...).Output()
+	cmd := exec.Command(name, args...)
+	output, err := c.mutator.IdempotentCmdOutput(cmd)
 	if err != nil {
 		panic(fmt.Errorf("pass: %s %s: %w", name, strings.Join(args, " "), err))
 	}
diff --git a/cmd/secretvault.go b/cmd/secretvault.go
index eccfcf5c5..cc7a0b7c7 100644
--- a/cmd/secretvault.go
+++ b/cmd/secretvault.go
@@ -8,14 +8,13 @@ import (
 	"strings"
 
 	"github.com/spf13/cobra"
-	vfs "github.com/twpayne/go-vfs"
 )
 
 var vaultCmd = &cobra.Command{
 	Use:     "vault [args...]",
 	Short:   "Execute the Hashicorp Vault CLI (vault)",
 	PreRunE: config.ensureNoError,
-	RunE:    makeRunE(config.runVaultCmd),
+	RunE:    config.runVaultCmd,
 }
 
 type vaultCmdConfig struct {
@@ -31,8 +30,8 @@ func init() {
 	secretCmd.AddCommand(vaultCmd)
 }
 
-func (c *Config) runVaultCmd(fs vfs.FS, args []string) error {
-	return c.exec(fs, append([]string{c.Vault.Command}, args...))
+func (c *Config) runVaultCmd(cmd *cobra.Command, args []string) error {
+	return c.run("", c.Vault.Command, args...)
 }
 
 func (c *Config) vaultFunc(key string) interface{} {
@@ -47,7 +46,7 @@ func (c *Config) vaultFunc(key string) interface{} {
 	cmd := exec.Command(name, args...)
 	cmd.Stdin = os.Stdin
 	cmd.Stderr = os.Stderr
-	output, err := cmd.Output()
+	output, err := c.mutator.IdempotentCmdOutput(cmd)
 	if err != nil {
 		panic(fmt.Errorf("vault: %s %s: %w\n%s", name, strings.Join(args, " "), err, output))
 	}
diff --git a/cmd/source.go b/cmd/source.go
index 659f22a6b..aa1bbbdac 100644
--- a/cmd/source.go
+++ b/cmd/source.go
@@ -1,11 +1,7 @@
 package cmd
 
 import (
-	"fmt"
-	"os"
-
 	"github.com/spf13/cobra"
-	vfs "github.com/twpayne/go-vfs"
 )
 
 var sourceCmd = &cobra.Command{
@@ -14,21 +10,13 @@ var sourceCmd = &cobra.Command{
 	Long:    mustGetLongHelp("source"),
 	Example: getExample("source"),
 	PreRunE: config.ensureNoError,
-	RunE:    makeRunE(config.runSourceCmd),
+	RunE:    config.runSourceCmd,
 }
 
 func init() {
 	rootCmd.AddCommand(sourceCmd)
 }
 
-func (c *Config) runSourceCmd(fs vfs.FS, args []string) error {
-	if c.Verbose {
-		fmt.Printf("cd %s\n", c.SourceDir)
-	}
-	if !c.DryRun {
-		if err := os.Chdir(c.SourceDir); err != nil {
-			return err
-		}
-	}
-	return c.exec(fs, append([]string{c.SourceVCS.Command}, args...))
+func (c *Config) runSourceCmd(cmd *cobra.Command, args []string) error {
+	return c.run(c.SourceDir, c.SourceVCS.Command, args...)
 }
diff --git a/cmd/sourcepath.go b/cmd/sourcepath.go
index 96eb28d33..25c5f0e5f 100644
--- a/cmd/sourcepath.go
+++ b/cmd/sourcepath.go
@@ -5,7 +5,6 @@ import (
 	"path/filepath"
 
 	"github.com/spf13/cobra"
-	vfs "github.com/twpayne/go-vfs"
 )
 
 var sourcePathCmd = &cobra.Command{
@@ -14,15 +13,15 @@ var sourcePathCmd = &cobra.Command{
 	Long:    mustGetLongHelp("source-path"),
 	Example: getExample("source-path"),
 	PreRunE: config.ensureNoError,
-	RunE:    makeRunE(config.runSourcePathCmd),
+	RunE:    config.runSourcePathCmd,
 }
 
 func init() {
 	rootCmd.AddCommand(sourcePathCmd)
 }
 
-func (c *Config) runSourcePathCmd(fs vfs.FS, args []string) error {
-	ts, err := c.getTargetState(fs, nil)
+func (c *Config) runSourcePathCmd(cmd *cobra.Command, args []string) error {
+	ts, err := c.getTargetState(nil)
 	if err != nil {
 		return err
 	}
@@ -30,7 +29,7 @@ func (c *Config) runSourcePathCmd(fs vfs.FS, args []string) error {
 		_, err := fmt.Println(ts.SourceDir)
 		return err
 	}
-	entries, err := c.getEntries(fs, ts, args)
+	entries, err := c.getEntries(ts, args)
 	if err != nil {
 		return err
 	}
diff --git a/cmd/unmanaged.go b/cmd/unmanaged.go
index 753632b42..c3c9d5f40 100644
--- a/cmd/unmanaged.go
+++ b/cmd/unmanaged.go
@@ -17,26 +17,26 @@ var unmanagedCmd = &cobra.Command{
 	Long:    mustGetLongHelp("unmanaged"),
 	Example: getExample("unmanaged"),
 	PreRunE: config.ensureNoError,
-	RunE:    makeRunE(config.runUnmanagedCmd),
+	RunE:    config.runUnmanagedCmd,
 }
 
 func init() {
 	rootCmd.AddCommand(unmanagedCmd)
 }
 
-func (c *Config) runUnmanagedCmd(fs vfs.FS, args []string) error {
-	ts, err := c.getTargetState(fs, nil)
+func (c *Config) runUnmanagedCmd(cmd *cobra.Command, args []string) error {
+	ts, err := c.getTargetState(nil)
 	if err != nil {
 		return err
 	}
-	return vfs.Walk(fs, c.DestDir, func(path string, info os.FileInfo, err error) error {
+	return vfs.Walk(c.fs, c.DestDir, func(path string, info os.FileInfo, err error) error {
 		if err != nil {
 			return err
 		}
 		if path == c.DestDir {
 			return nil
 		}
-		entry, _ := ts.Get(fs, path)
+		entry, _ := ts.Get(c.fs, path)
 		managed := entry != nil
 		ignored := ts.TargetIgnore.Match(strings.TrimPrefix(path, c.DestDir+"/"))
 		if !managed && !ignored {
diff --git a/cmd/update.go b/cmd/update.go
index 1e66ff31d..4da6281c6 100644
--- a/cmd/update.go
+++ b/cmd/update.go
@@ -5,7 +5,6 @@ import (
 	"strings"
 
 	"github.com/spf13/cobra"
-	vfs "github.com/twpayne/go-vfs"
 )
 
 type updateCmdConfig struct {
@@ -19,7 +18,7 @@ var updateCmd = &cobra.Command{
 	Long:    mustGetLongHelp("update"),
 	Example: getExample("update"),
 	PreRunE: config.ensureNoError,
-	RunE:    makeRunE(config.runUpdateCmd),
+	RunE:    config.runUpdateCmd,
 }
 
 func init() {
@@ -29,7 +28,7 @@ func init() {
 	persistentFlags.BoolVarP(&config.update.apply, "apply", "a", true, "apply after pulling")
 }
 
-func (c *Config) runUpdateCmd(fs vfs.FS, args []string) error {
+func (c *Config) runUpdateCmd(cmd *cobra.Command, args []string) error {
 	vcs, err := c.getVCS()
 	if err != nil {
 		return err
@@ -51,18 +50,17 @@ func (c *Config) runUpdateCmd(fs vfs.FS, args []string) error {
 		return fmt.Errorf("%s: pull not supported", c.SourceVCS.Command)
 	}
 
-	if err := c.run(fs, c.SourceDir, c.SourceVCS.Command, pullArgs...); err != nil {
+	if err := c.run(c.SourceDir, c.SourceVCS.Command, pullArgs...); err != nil {
 		return err
 	}
 
 	if c.update.apply {
-		mutator := c.getDefaultMutator(fs)
-		persistentState, err := c.getPersistentState(fs, nil)
+		persistentState, err := c.getPersistentState(nil)
 		if err != nil {
 			return err
 		}
 		defer persistentState.Close()
-		if err := c.applyArgs(fs, nil, mutator, persistentState); err != nil {
+		if err := c.applyArgs(nil, persistentState); err != nil {
 			return err
 		}
 	}
diff --git a/cmd/upgrade.go b/cmd/upgrade.go
index 62ff33df4..599a315f1 100644
--- a/cmd/upgrade.go
+++ b/cmd/upgrade.go
@@ -26,7 +26,6 @@ import (
 	"github.com/coreos/go-semver/semver"
 	"github.com/google/go-github/v26/github"
 	"github.com/spf13/cobra"
-	"github.com/twpayne/chezmoi/internal/chezmoi"
 	vfs "github.com/twpayne/go-vfs"
 	"golang.org/x/oauth2"
 )
@@ -78,7 +77,7 @@ var upgradeCmd = &cobra.Command{
 	Short:   "Upgrade chezmoi",
 	Long:    mustGetLongHelp("upgrade"),
 	Example: getExample("upgrade"),
-	RunE:    makeRunE(config.runUpgradeCmd),
+	RunE:    config.runUpgradeCmd,
 }
 
 type upgradeCmdConfig struct {
@@ -98,7 +97,7 @@ func init() {
 	persistentFlags.StringVarP(&config.upgrade.repo, "repo", "r", "chezmoi", "set repo")
 }
 
-func (c *Config) runUpgradeCmd(fs vfs.FS, args []string) error {
+func (c *Config) runUpgradeCmd(cmd *cobra.Command, args []string) error {
 	ctx := context.Background()
 
 	// Use a GitHub API token, if set.
@@ -137,29 +136,28 @@ func (c *Config) runUpgradeCmd(fs vfs.FS, args []string) error {
 	}
 	method := c.upgrade.method
 	if method == "" {
-		method, err = getMethod(fs, executableFilename)
+		method, err = getMethod(c.fs, executableFilename)
 		if err != nil {
 			return err
 		}
 	}
 
 	// Replace the executable with the updated version.
-	mutator := c.getDefaultMutator(fs)
 	switch method {
 	case methodReplaceExecutable:
-		if err := c.replaceExecutable(mutator, executableFilename, releaseVersion, rr); err != nil {
+		if err := c.replaceExecutable(executableFilename, releaseVersion, rr); err != nil {
 			return err
 		}
 	case methodSnapRefresh:
-		if err := c.snapRefresh(fs); err != nil {
+		if err := c.snapRefresh(); err != nil {
 			return err
 		}
 	case methodUpgradePackage:
-		if err := c.upgradePackage(fs, mutator, rr, false); err != nil {
+		if err := c.upgradePackage(rr, false); err != nil {
 			return err
 		}
 	case methodSudoPrefix + methodUpgradePackage:
-		if err := c.upgradePackage(fs, mutator, rr, true); err != nil {
+		if err := c.upgradePackage(rr, true); err != nil {
 			return err
 		}
 	default:
@@ -230,7 +228,7 @@ func (c *Config) downloadURL(url string) ([]byte, error) {
 	return data, nil
 }
 
-func (c *Config) replaceExecutable(mutator chezmoi.Mutator, executableFilename string, releaseVersion *semver.Version, rr *github.RepositoryRelease) error {
+func (c *Config) replaceExecutable(executableFilename string, releaseVersion *semver.Version, rr *github.RepositoryRelease) error {
 	name := fmt.Sprintf("%s_%s_%s_%s.tar.gz", c.upgrade.repo, releaseVersion, runtime.GOOS, runtime.GOARCH)
 	releaseAsset := getReleaseAssetByName(rr, name)
 	if releaseAsset == nil {
@@ -268,20 +266,20 @@ FOR:
 		}
 	}
 
-	return mutator.WriteFile(executableFilename, executableData, 0755, nil)
+	return c.mutator.WriteFile(executableFilename, executableData, 0755, nil)
 }
 
-func (c *Config) snapRefresh(fs vfs.FS) error {
-	return c.run(fs, "", "snap", "refresh", c.upgrade.repo)
+func (c *Config) snapRefresh() error {
+	return c.run("", "snap", "refresh", c.upgrade.repo)
 }
 
-func (c *Config) upgradePackage(fs vfs.FS, mutator chezmoi.Mutator, rr *github.RepositoryRelease, useSudo bool) error {
+func (c *Config) upgradePackage(rr *github.RepositoryRelease, useSudo bool) error {
 	switch runtime.GOOS {
 	case "darwin":
-		return c.run(fs, "", "brew", "upgrade", c.upgrade.repo)
+		return c.run("", "brew", "upgrade", c.upgrade.repo)
 	case "linux":
 		// Determine the package type and architecture.
-		packageType, err := getPackageType(fs)
+		packageType, err := getPackageType(c.fs)
 		if err != nil {
 			return err
 		}
@@ -298,7 +296,7 @@ func (c *Config) upgradePackage(fs vfs.FS, mutator chezmoi.Mutator, rr *github.R
 				args = append(args, "sudo")
 			}
 			args = append(args, "pacman", "-S", c.upgrade.repo)
-			return c.run(fs, "", args[0], args[1:]...)
+			return c.run("", args[0], args[1:]...)
 		}
 
 		// Find the corresponding release asset.
@@ -327,7 +325,7 @@ func (c *Config) upgradePackage(fs vfs.FS, mutator chezmoi.Mutator, rr *github.R
 				return err
 			}
 			defer func() {
-				_ = mutator.RemoveAll(tempDir)
+				_ = c.mutator.RemoveAll(tempDir)
 			}()
 		}
 
@@ -340,7 +338,7 @@ func (c *Config) upgradePackage(fs vfs.FS, mutator chezmoi.Mutator, rr *github.R
 		}
 
 		packageFilename := filepath.Join(tempDir, releaseAsset.GetName())
-		if err := mutator.WriteFile(packageFilename, data, 0644, nil); err != nil {
+		if err := c.mutator.WriteFile(packageFilename, data, 0644, nil); err != nil {
 			return err
 		}
 
@@ -355,7 +353,7 @@ func (c *Config) upgradePackage(fs vfs.FS, mutator chezmoi.Mutator, rr *github.R
 		case packageTypeRPM:
 			args = append(args, "rpm", "-U", packageFilename)
 		}
-		return c.run(fs, "", args[0], args[1:]...)
+		return c.run("", args[0], args[1:]...)
 	default:
 		return fmt.Errorf("%s: unsupported GOOS", runtime.GOOS)
 	}
diff --git a/cmd/verify.go b/cmd/verify.go
index 7fb91a0e3..23436247b 100644
--- a/cmd/verify.go
+++ b/cmd/verify.go
@@ -5,7 +5,6 @@ import (
 
 	"github.com/spf13/cobra"
 	"github.com/twpayne/chezmoi/internal/chezmoi"
-	vfs "github.com/twpayne/go-vfs"
 	bolt "go.etcd.io/bbolt"
 )
 
@@ -15,17 +14,18 @@ var verifyCmd = &cobra.Command{
 	Long:    mustGetLongHelp("verify"),
 	Example: getExample("verify"),
 	PreRunE: config.ensureNoError,
-	RunE:    makeRunE(config.runVerifyCmd),
+	RunE:    config.runVerifyCmd,
 }
 
 func init() {
 	rootCmd.AddCommand(verifyCmd)
 }
 
-func (c *Config) runVerifyCmd(fs vfs.FS, args []string) error {
+func (c *Config) runVerifyCmd(cmd *cobra.Command, args []string) error {
 	mutator := chezmoi.NewAnyMutator(chezmoi.NullMutator{})
+	c.mutator = mutator
 
-	persistentState, err := c.getPersistentState(fs, &bolt.Options{
+	persistentState, err := c.getPersistentState(&bolt.Options{
 		ReadOnly: true,
 	})
 	if err != nil {
@@ -33,7 +33,7 @@ func (c *Config) runVerifyCmd(fs vfs.FS, args []string) error {
 	}
 	defer persistentState.Close()
 
-	if err := c.applyArgs(fs, args, mutator, persistentState); err != nil {
+	if err := c.applyArgs(args, persistentState); err != nil {
 		return err
 	}
 	if mutator.Mutated() {
diff --git a/completions/chezmoi-completion.bash b/completions/chezmoi-completion.bash
index 84b25d3f3..e38f07efd 100644
--- a/completions/chezmoi-completion.bash
+++ b/completions/chezmoi-completion.bash
@@ -282,6 +282,7 @@ _chezmoi_add()
     flags+=("--config=")
     two_word_flags+=("--config")
     two_word_flags+=("-c")
+    flags+=("--debug")
     flags+=("--destination=")
     two_word_flags+=("--destination")
     two_word_flags+=("-D")
@@ -319,6 +320,7 @@ _chezmoi_apply()
     flags+=("--config=")
     two_word_flags+=("--config")
     two_word_flags+=("-c")
+    flags+=("--debug")
     flags+=("--destination=")
     two_word_flags+=("--destination")
     two_word_flags+=("-D")
@@ -356,6 +358,7 @@ _chezmoi_archive()
     flags+=("--config=")
     two_word_flags+=("--config")
     two_word_flags+=("-c")
+    flags+=("--debug")
     flags+=("--destination=")
     two_word_flags+=("--destination")
     two_word_flags+=("-D")
@@ -393,6 +396,7 @@ _chezmoi_cat()
     flags+=("--config=")
     two_word_flags+=("--config")
     two_word_flags+=("-c")
+    flags+=("--debug")
     flags+=("--destination=")
     two_word_flags+=("--destination")
     two_word_flags+=("-D")
@@ -430,6 +434,7 @@ _chezmoi_cd()
     flags+=("--config=")
     two_word_flags+=("--config")
     two_word_flags+=("-c")
+    flags+=("--debug")
     flags+=("--destination=")
     two_word_flags+=("--destination")
     two_word_flags+=("-D")
@@ -467,6 +472,7 @@ _chezmoi_chattr()
     flags+=("--config=")
     two_word_flags+=("--config")
     two_word_flags+=("-c")
+    flags+=("--debug")
     flags+=("--destination=")
     two_word_flags+=("--destination")
     two_word_flags+=("-D")
@@ -507,6 +513,7 @@ _chezmoi_completion()
     flags+=("--config=")
     two_word_flags+=("--config")
     two_word_flags+=("-c")
+    flags+=("--debug")
     flags+=("--destination=")
     two_word_flags+=("--destination")
     two_word_flags+=("-D")
@@ -550,6 +557,7 @@ _chezmoi_data()
     flags+=("--config=")
     two_word_flags+=("--config")
     two_word_flags+=("-c")
+    flags+=("--debug")
     flags+=("--destination=")
     two_word_flags+=("--destination")
     two_word_flags+=("-D")
@@ -587,6 +595,7 @@ _chezmoi_diff()
     flags+=("--config=")
     two_word_flags+=("--config")
     two_word_flags+=("-c")
+    flags+=("--debug")
     flags+=("--destination=")
     two_word_flags+=("--destination")
     two_word_flags+=("-D")
@@ -624,6 +633,7 @@ _chezmoi_docs()
     flags+=("--config=")
     two_word_flags+=("--config")
     two_word_flags+=("-c")
+    flags+=("--debug")
     flags+=("--destination=")
     two_word_flags+=("--destination")
     two_word_flags+=("-D")
@@ -661,6 +671,7 @@ _chezmoi_doctor()
     flags+=("--config=")
     two_word_flags+=("--config")
     two_word_flags+=("-c")
+    flags+=("--debug")
     flags+=("--destination=")
     two_word_flags+=("--destination")
     two_word_flags+=("-D")
@@ -703,6 +714,7 @@ _chezmoi_dump()
     flags+=("--config=")
     two_word_flags+=("--config")
     two_word_flags+=("-c")
+    flags+=("--debug")
     flags+=("--destination=")
     two_word_flags+=("--destination")
     two_word_flags+=("-D")
@@ -746,6 +758,7 @@ _chezmoi_edit()
     flags+=("--config=")
     two_word_flags+=("--config")
     two_word_flags+=("-c")
+    flags+=("--debug")
     flags+=("--destination=")
     two_word_flags+=("--destination")
     two_word_flags+=("-D")
@@ -783,6 +796,7 @@ _chezmoi_edit-config()
     flags+=("--config=")
     two_word_flags+=("--config")
     two_word_flags+=("-c")
+    flags+=("--debug")
     flags+=("--destination=")
     two_word_flags+=("--destination")
     two_word_flags+=("-D")
@@ -820,6 +834,7 @@ _chezmoi_forget()
     flags+=("--config=")
     two_word_flags+=("--config")
     two_word_flags+=("-c")
+    flags+=("--debug")
     flags+=("--destination=")
     two_word_flags+=("--destination")
     two_word_flags+=("-D")
@@ -863,6 +878,7 @@ _chezmoi_import()
     flags+=("--config=")
     two_word_flags+=("--config")
     two_word_flags+=("-c")
+    flags+=("--debug")
     flags+=("--destination=")
     two_word_flags+=("--destination")
     two_word_flags+=("-D")
@@ -901,6 +917,7 @@ _chezmoi_init()
     flags+=("--config=")
     two_word_flags+=("--config")
     two_word_flags+=("-c")
+    flags+=("--debug")
     flags+=("--destination=")
     two_word_flags+=("--destination")
     two_word_flags+=("-D")
@@ -938,6 +955,7 @@ _chezmoi_merge()
     flags+=("--config=")
     two_word_flags+=("--config")
     two_word_flags+=("-c")
+    flags+=("--debug")
     flags+=("--destination=")
     two_word_flags+=("--destination")
     two_word_flags+=("-D")
@@ -977,6 +995,7 @@ _chezmoi_remove()
     flags+=("--config=")
     two_word_flags+=("--config")
     two_word_flags+=("-c")
+    flags+=("--debug")
     flags+=("--destination=")
     two_word_flags+=("--destination")
     two_word_flags+=("-D")
@@ -1014,6 +1033,7 @@ _chezmoi_secret_bitwarden()
     flags+=("--config=")
     two_word_flags+=("--config")
     two_word_flags+=("-c")
+    flags+=("--debug")
     flags+=("--destination=")
     two_word_flags+=("--destination")
     two_word_flags+=("-D")
@@ -1051,6 +1071,7 @@ _chezmoi_secret_generic()
     flags+=("--config=")
     two_word_flags+=("--config")
     two_word_flags+=("-c")
+    flags+=("--debug")
     flags+=("--destination=")
     two_word_flags+=("--destination")
     two_word_flags+=("-D")
@@ -1088,6 +1109,7 @@ _chezmoi_secret_gopass()
     flags+=("--config=")
     two_word_flags+=("--config")
     two_word_flags+=("-c")
+    flags+=("--debug")
     flags+=("--destination=")
     two_word_flags+=("--destination")
     two_word_flags+=("-D")
@@ -1125,6 +1147,7 @@ _chezmoi_secret_keepassxc()
     flags+=("--config=")
     two_word_flags+=("--config")
     two_word_flags+=("-c")
+    flags+=("--debug")
     flags+=("--destination=")
     two_word_flags+=("--destination")
     two_word_flags+=("-D")
@@ -1162,6 +1185,7 @@ _chezmoi_secret_keyring_get()
     flags+=("--config=")
     two_word_flags+=("--config")
     two_word_flags+=("-c")
+    flags+=("--debug")
     flags+=("--destination=")
     two_word_flags+=("--destination")
     two_word_flags+=("-D")
@@ -1205,6 +1229,7 @@ _chezmoi_secret_keyring_set()
     flags+=("--config=")
     two_word_flags+=("--config")
     two_word_flags+=("-c")
+    flags+=("--debug")
     flags+=("--destination=")
     two_word_flags+=("--destination")
     two_word_flags+=("-D")
@@ -1252,6 +1277,7 @@ _chezmoi_secret_keyring()
     flags+=("--config=")
     two_word_flags+=("--config")
     two_word_flags+=("-c")
+    flags+=("--debug")
     flags+=("--destination=")
     two_word_flags+=("--destination")
     two_word_flags+=("-D")
@@ -1291,6 +1317,7 @@ _chezmoi_secret_lastpass()
     flags+=("--config=")
     two_word_flags+=("--config")
     two_word_flags+=("-c")
+    flags+=("--debug")
     flags+=("--destination=")
     two_word_flags+=("--destination")
     two_word_flags+=("-D")
@@ -1328,6 +1355,7 @@ _chezmoi_secret_onepassword()
     flags+=("--config=")
     two_word_flags+=("--config")
     two_word_flags+=("-c")
+    flags+=("--debug")
     flags+=("--destination=")
     two_word_flags+=("--destination")
     two_word_flags+=("-D")
@@ -1365,6 +1393,7 @@ _chezmoi_secret_pass()
     flags+=("--config=")
     two_word_flags+=("--config")
     two_word_flags+=("-c")
+    flags+=("--debug")
     flags+=("--destination=")
     two_word_flags+=("--destination")
     two_word_flags+=("-D")
@@ -1402,6 +1431,7 @@ _chezmoi_secret_vault()
     flags+=("--config=")
     two_word_flags+=("--config")
     two_word_flags+=("-c")
+    flags+=("--debug")
     flags+=("--destination=")
     two_word_flags+=("--destination")
     two_word_flags+=("-D")
@@ -1448,6 +1478,7 @@ _chezmoi_secret()
     flags+=("--config=")
     two_word_flags+=("--config")
     two_word_flags+=("-c")
+    flags+=("--debug")
     flags+=("--destination=")
     two_word_flags+=("--destination")
     two_word_flags+=("-D")
@@ -1485,6 +1516,7 @@ _chezmoi_source()
     flags+=("--config=")
     two_word_flags+=("--config")
     two_word_flags+=("-c")
+    flags+=("--debug")
     flags+=("--destination=")
     two_word_flags+=("--destination")
     two_word_flags+=("-D")
@@ -1522,6 +1554,7 @@ _chezmoi_source-path()
     flags+=("--config=")
     two_word_flags+=("--config")
     two_word_flags+=("-c")
+    flags+=("--debug")
     flags+=("--destination=")
     two_word_flags+=("--destination")
     two_word_flags+=("-D")
@@ -1559,6 +1592,7 @@ _chezmoi_unmanaged()
     flags+=("--config=")
     two_word_flags+=("--config")
     two_word_flags+=("-c")
+    flags+=("--debug")
     flags+=("--destination=")
     two_word_flags+=("--destination")
     two_word_flags+=("-D")
@@ -1598,6 +1632,7 @@ _chezmoi_update()
     flags+=("--config=")
     two_word_flags+=("--config")
     two_word_flags+=("-c")
+    flags+=("--debug")
     flags+=("--destination=")
     two_word_flags+=("--destination")
     two_word_flags+=("-D")
@@ -1646,6 +1681,7 @@ _chezmoi_upgrade()
     flags+=("--config=")
     two_word_flags+=("--config")
     two_word_flags+=("-c")
+    flags+=("--debug")
     flags+=("--destination=")
     two_word_flags+=("--destination")
     two_word_flags+=("-D")
@@ -1683,6 +1719,7 @@ _chezmoi_verify()
     flags+=("--config=")
     two_word_flags+=("--config")
     two_word_flags+=("-c")
+    flags+=("--debug")
     flags+=("--destination=")
     two_word_flags+=("--destination")
     two_word_flags+=("-D")
@@ -1758,6 +1795,7 @@ _chezmoi_root_command()
     flags+=("--config=")
     two_word_flags+=("--config")
     two_word_flags+=("-c")
+    flags+=("--debug")
     flags+=("--destination=")
     two_word_flags+=("--destination")
     two_word_flags+=("-D")
diff --git a/completions/chezmoi.fish b/completions/chezmoi.fish
index 6613d6265..964f811cf 100644
--- a/completions/chezmoi.fish
+++ b/completions/chezmoi.fish
@@ -63,6 +63,7 @@ complete -c chezmoi -f -n '__fish_chezmoi_no_subcommand' -a upgrade -d 'Upgrade
 complete -c chezmoi -f -n '__fish_chezmoi_no_subcommand' -a verify -d 'Exit with success if the destination state matches the target state, fail otherwise'
 complete -c chezmoi -f -n '__fish_chezmoi_no_subcommand' -r  -l color -d 'colorize diffs'
 complete -c chezmoi -f -n '__fish_chezmoi_no_subcommand' -r -s c -l config -d 'config file'
+complete -c chezmoi -f -n '__fish_chezmoi_no_subcommand'   -l debug -d 'write debug logs'
 complete -c chezmoi -f -n '__fish_chezmoi_no_subcommand' -r -s D -l destination -d 'destination directory'
 complete -c chezmoi -f -n '__fish_chezmoi_no_subcommand'  -s n -l dry-run -d 'dry run'
 complete -c chezmoi -f -n '__fish_chezmoi_no_subcommand'   -l follow -d 'follow symlinks'
@@ -78,6 +79,7 @@ complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path add'  -s r -l rec
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path add'  -s T -l template -d 'add files as templates'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path add' -r  -l color -d 'colorize diffs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path add' -r -s c -l config -d 'config file'
+complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path add'   -l debug -d 'write debug logs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path add' -r -s D -l destination -d 'destination directory'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path add'  -s n -l dry-run -d 'dry run'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path add'   -l follow -d 'follow symlinks'
@@ -86,6 +88,7 @@ complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path add' -r -s S -l s
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path add'  -s v -l verbose -d 'verbose'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path apply' -r  -l color -d 'colorize diffs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path apply' -r -s c -l config -d 'config file'
+complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path apply'   -l debug -d 'write debug logs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path apply' -r -s D -l destination -d 'destination directory'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path apply'  -s n -l dry-run -d 'dry run'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path apply'   -l follow -d 'follow symlinks'
@@ -94,6 +97,7 @@ complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path apply' -r -s S -l
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path apply'  -s v -l verbose -d 'verbose'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path archive' -r  -l color -d 'colorize diffs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path archive' -r -s c -l config -d 'config file'
+complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path archive'   -l debug -d 'write debug logs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path archive' -r -s D -l destination -d 'destination directory'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path archive'  -s n -l dry-run -d 'dry run'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path archive'   -l follow -d 'follow symlinks'
@@ -102,6 +106,7 @@ complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path archive' -r -s S
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path archive'  -s v -l verbose -d 'verbose'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path cat' -r  -l color -d 'colorize diffs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path cat' -r -s c -l config -d 'config file'
+complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path cat'   -l debug -d 'write debug logs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path cat' -r -s D -l destination -d 'destination directory'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path cat'  -s n -l dry-run -d 'dry run'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path cat'   -l follow -d 'follow symlinks'
@@ -110,6 +115,7 @@ complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path cat' -r -s S -l s
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path cat'  -s v -l verbose -d 'verbose'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path cd' -r  -l color -d 'colorize diffs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path cd' -r -s c -l config -d 'config file'
+complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path cd'   -l debug -d 'write debug logs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path cd' -r -s D -l destination -d 'destination directory'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path cd'  -s n -l dry-run -d 'dry run'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path cd'   -l follow -d 'follow symlinks'
@@ -118,6 +124,7 @@ complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path cd' -r -s S -l so
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path cd'  -s v -l verbose -d 'verbose'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path chattr' -r  -l color -d 'colorize diffs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path chattr' -r -s c -l config -d 'config file'
+complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path chattr'   -l debug -d 'write debug logs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path chattr' -r -s D -l destination -d 'destination directory'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path chattr'  -s n -l dry-run -d 'dry run'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path chattr'   -l follow -d 'follow symlinks'
@@ -130,6 +137,7 @@ complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path completion; and n
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path completion'  -s h -l help -d 'help for completion'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path completion' -r  -l color -d 'colorize diffs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path completion' -r -s c -l config -d 'config file'
+complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path completion'   -l debug -d 'write debug logs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path completion' -r -s D -l destination -d 'destination directory'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path completion'  -s n -l dry-run -d 'dry run'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path completion'   -l follow -d 'follow symlinks'
@@ -139,6 +147,7 @@ complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path completion'  -s v
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path data' -r -s f -l format -d 'format (JSON, TOML, or YAML)'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path data' -r  -l color -d 'colorize diffs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path data' -r -s c -l config -d 'config file'
+complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path data'   -l debug -d 'write debug logs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path data' -r -s D -l destination -d 'destination directory'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path data'  -s n -l dry-run -d 'dry run'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path data'   -l follow -d 'follow symlinks'
@@ -147,6 +156,7 @@ complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path data' -r -s S -l
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path data'  -s v -l verbose -d 'verbose'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path diff' -r  -l color -d 'colorize diffs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path diff' -r -s c -l config -d 'config file'
+complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path diff'   -l debug -d 'write debug logs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path diff' -r -s D -l destination -d 'destination directory'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path diff'  -s n -l dry-run -d 'dry run'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path diff'   -l follow -d 'follow symlinks'
@@ -155,6 +165,7 @@ complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path diff' -r -s S -l
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path diff'  -s v -l verbose -d 'verbose'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path docs' -r  -l color -d 'colorize diffs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path docs' -r -s c -l config -d 'config file'
+complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path docs'   -l debug -d 'write debug logs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path docs' -r -s D -l destination -d 'destination directory'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path docs'  -s n -l dry-run -d 'dry run'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path docs'   -l follow -d 'follow symlinks'
@@ -163,6 +174,7 @@ complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path docs' -r -s S -l
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path docs'  -s v -l verbose -d 'verbose'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path doctor' -r  -l color -d 'colorize diffs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path doctor' -r -s c -l config -d 'config file'
+complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path doctor'   -l debug -d 'write debug logs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path doctor' -r -s D -l destination -d 'destination directory'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path doctor'  -s n -l dry-run -d 'dry run'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path doctor'   -l follow -d 'follow symlinks'
@@ -173,6 +185,7 @@ complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path dump' -r -s f -l
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path dump'  -s r -l recursive -d 'recursive'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path dump' -r  -l color -d 'colorize diffs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path dump' -r -s c -l config -d 'config file'
+complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path dump'   -l debug -d 'write debug logs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path dump' -r -s D -l destination -d 'destination directory'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path dump'  -s n -l dry-run -d 'dry run'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path dump'   -l follow -d 'follow symlinks'
@@ -184,6 +197,7 @@ complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path edit'  -s d -l di
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path edit'  -s p -l prompt -d 'prompt before applying (implies --diff)'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path edit' -r  -l color -d 'colorize diffs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path edit' -r -s c -l config -d 'config file'
+complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path edit'   -l debug -d 'write debug logs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path edit' -r -s D -l destination -d 'destination directory'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path edit'  -s n -l dry-run -d 'dry run'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path edit'   -l follow -d 'follow symlinks'
@@ -192,6 +206,7 @@ complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path edit' -r -s S -l
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path edit'  -s v -l verbose -d 'verbose'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path edit-config' -r  -l color -d 'colorize diffs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path edit-config' -r -s c -l config -d 'config file'
+complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path edit-config'   -l debug -d 'write debug logs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path edit-config' -r -s D -l destination -d 'destination directory'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path edit-config'  -s n -l dry-run -d 'dry run'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path edit-config'   -l follow -d 'follow symlinks'
@@ -200,6 +215,7 @@ complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path edit-config' -r -
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path edit-config'  -s v -l verbose -d 'verbose'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path forget' -r  -l color -d 'colorize diffs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path forget' -r -s c -l config -d 'config file'
+complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path forget'   -l debug -d 'write debug logs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path forget' -r -s D -l destination -d 'destination directory'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path forget'  -s n -l dry-run -d 'dry run'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path forget'   -l follow -d 'follow symlinks'
@@ -211,6 +227,7 @@ complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path import'  -s r -l
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path import' -r  -l strip-components -d 'strip components'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path import' -r  -l color -d 'colorize diffs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path import' -r -s c -l config -d 'config file'
+complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path import'   -l debug -d 'write debug logs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path import' -r -s D -l destination -d 'destination directory'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path import'  -s n -l dry-run -d 'dry run'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path import'   -l follow -d 'follow symlinks'
@@ -220,6 +237,7 @@ complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path import'  -s v -l
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path init'   -l apply -d 'update destination directory'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path init' -r  -l color -d 'colorize diffs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path init' -r -s c -l config -d 'config file'
+complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path init'   -l debug -d 'write debug logs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path init' -r -s D -l destination -d 'destination directory'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path init'  -s n -l dry-run -d 'dry run'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path init'   -l follow -d 'follow symlinks'
@@ -228,6 +246,7 @@ complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path init' -r -s S -l
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path init'  -s v -l verbose -d 'verbose'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path merge' -r  -l color -d 'colorize diffs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path merge' -r -s c -l config -d 'config file'
+complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path merge'   -l debug -d 'write debug logs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path merge' -r -s D -l destination -d 'destination directory'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path merge'  -s n -l dry-run -d 'dry run'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path merge'   -l follow -d 'follow symlinks'
@@ -237,6 +256,7 @@ complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path merge'  -s v -l v
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path remove'  -s f -l force -d 'remove without prompting'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path remove' -r  -l color -d 'colorize diffs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path remove' -r -s c -l config -d 'config file'
+complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path remove'   -l debug -d 'write debug logs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path remove' -r -s D -l destination -d 'destination directory'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path remove'  -s n -l dry-run -d 'dry run'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path remove'   -l follow -d 'follow symlinks'
@@ -254,6 +274,7 @@ complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret' -a pass -
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret' -a vault -d 'Execute the Hashicorp Vault CLI (vault)'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret' -r  -l color -d 'colorize diffs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret' -r -s c -l config -d 'config file'
+complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret'   -l debug -d 'write debug logs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret' -r -s D -l destination -d 'destination directory'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret'  -s n -l dry-run -d 'dry run'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret'   -l follow -d 'follow symlinks'
@@ -262,6 +283,7 @@ complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret' -r -s S -
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret'  -s v -l verbose -d 'verbose'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret bitwarden' -r  -l color -d 'colorize diffs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret bitwarden' -r -s c -l config -d 'config file'
+complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret bitwarden'   -l debug -d 'write debug logs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret bitwarden' -r -s D -l destination -d 'destination directory'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret bitwarden'  -s n -l dry-run -d 'dry run'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret bitwarden'   -l follow -d 'follow symlinks'
@@ -270,6 +292,7 @@ complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret bitwarden'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret bitwarden'  -s v -l verbose -d 'verbose'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret generic' -r  -l color -d 'colorize diffs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret generic' -r -s c -l config -d 'config file'
+complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret generic'   -l debug -d 'write debug logs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret generic' -r -s D -l destination -d 'destination directory'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret generic'  -s n -l dry-run -d 'dry run'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret generic'   -l follow -d 'follow symlinks'
@@ -278,6 +301,7 @@ complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret generic' -
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret generic'  -s v -l verbose -d 'verbose'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret gopass' -r  -l color -d 'colorize diffs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret gopass' -r -s c -l config -d 'config file'
+complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret gopass'   -l debug -d 'write debug logs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret gopass' -r -s D -l destination -d 'destination directory'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret gopass'  -s n -l dry-run -d 'dry run'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret gopass'   -l follow -d 'follow symlinks'
@@ -286,6 +310,7 @@ complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret gopass' -r
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret gopass'  -s v -l verbose -d 'verbose'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret keepassxc' -r  -l color -d 'colorize diffs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret keepassxc' -r -s c -l config -d 'config file'
+complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret keepassxc'   -l debug -d 'write debug logs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret keepassxc' -r -s D -l destination -d 'destination directory'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret keepassxc'  -s n -l dry-run -d 'dry run'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret keepassxc'   -l follow -d 'follow symlinks'
@@ -298,6 +323,7 @@ complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret keyring' -
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret keyring' -r  -l user -d 'user'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret keyring' -r  -l color -d 'colorize diffs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret keyring' -r -s c -l config -d 'config file'
+complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret keyring'   -l debug -d 'write debug logs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret keyring' -r -s D -l destination -d 'destination directory'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret keyring'  -s n -l dry-run -d 'dry run'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret keyring'   -l follow -d 'follow symlinks'
@@ -306,6 +332,7 @@ complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret keyring' -
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret keyring'  -s v -l verbose -d 'verbose'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret keyring get' -r  -l color -d 'colorize diffs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret keyring get' -r -s c -l config -d 'config file'
+complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret keyring get'   -l debug -d 'write debug logs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret keyring get' -r -s D -l destination -d 'destination directory'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret keyring get'  -s n -l dry-run -d 'dry run'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret keyring get'   -l follow -d 'follow symlinks'
@@ -317,6 +344,7 @@ complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret keyring ge
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret keyring set' -r  -l password -d 'password'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret keyring set' -r  -l color -d 'colorize diffs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret keyring set' -r -s c -l config -d 'config file'
+complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret keyring set'   -l debug -d 'write debug logs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret keyring set' -r -s D -l destination -d 'destination directory'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret keyring set'  -s n -l dry-run -d 'dry run'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret keyring set'   -l follow -d 'follow symlinks'
@@ -327,6 +355,7 @@ complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret keyring se
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret keyring set'  -s v -l verbose -d 'verbose'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret lastpass' -r  -l color -d 'colorize diffs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret lastpass' -r -s c -l config -d 'config file'
+complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret lastpass'   -l debug -d 'write debug logs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret lastpass' -r -s D -l destination -d 'destination directory'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret lastpass'  -s n -l dry-run -d 'dry run'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret lastpass'   -l follow -d 'follow symlinks'
@@ -335,6 +364,7 @@ complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret lastpass'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret lastpass'  -s v -l verbose -d 'verbose'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret onepassword' -r  -l color -d 'colorize diffs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret onepassword' -r -s c -l config -d 'config file'
+complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret onepassword'   -l debug -d 'write debug logs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret onepassword' -r -s D -l destination -d 'destination directory'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret onepassword'  -s n -l dry-run -d 'dry run'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret onepassword'   -l follow -d 'follow symlinks'
@@ -343,6 +373,7 @@ complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret onepasswor
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret onepassword'  -s v -l verbose -d 'verbose'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret pass' -r  -l color -d 'colorize diffs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret pass' -r -s c -l config -d 'config file'
+complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret pass'   -l debug -d 'write debug logs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret pass' -r -s D -l destination -d 'destination directory'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret pass'  -s n -l dry-run -d 'dry run'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret pass'   -l follow -d 'follow symlinks'
@@ -351,6 +382,7 @@ complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret pass' -r -
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret pass'  -s v -l verbose -d 'verbose'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret vault' -r  -l color -d 'colorize diffs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret vault' -r -s c -l config -d 'config file'
+complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret vault'   -l debug -d 'write debug logs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret vault' -r -s D -l destination -d 'destination directory'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret vault'  -s n -l dry-run -d 'dry run'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret vault'   -l follow -d 'follow symlinks'
@@ -359,6 +391,7 @@ complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret vault' -r
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path secret vault'  -s v -l verbose -d 'verbose'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path source' -r  -l color -d 'colorize diffs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path source' -r -s c -l config -d 'config file'
+complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path source'   -l debug -d 'write debug logs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path source' -r -s D -l destination -d 'destination directory'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path source'  -s n -l dry-run -d 'dry run'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path source'   -l follow -d 'follow symlinks'
@@ -367,6 +400,7 @@ complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path source' -r -s S -
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path source'  -s v -l verbose -d 'verbose'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path source-path' -r  -l color -d 'colorize diffs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path source-path' -r -s c -l config -d 'config file'
+complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path source-path'   -l debug -d 'write debug logs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path source-path' -r -s D -l destination -d 'destination directory'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path source-path'  -s n -l dry-run -d 'dry run'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path source-path'   -l follow -d 'follow symlinks'
@@ -375,6 +409,7 @@ complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path source-path' -r -
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path source-path'  -s v -l verbose -d 'verbose'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path unmanaged' -r  -l color -d 'colorize diffs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path unmanaged' -r -s c -l config -d 'config file'
+complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path unmanaged'   -l debug -d 'write debug logs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path unmanaged' -r -s D -l destination -d 'destination directory'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path unmanaged'  -s n -l dry-run -d 'dry run'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path unmanaged'   -l follow -d 'follow symlinks'
@@ -384,6 +419,7 @@ complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path unmanaged'  -s v
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path update'  -s a -l apply -d 'apply after pulling'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path update' -r  -l color -d 'colorize diffs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path update' -r -s c -l config -d 'config file'
+complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path update'   -l debug -d 'write debug logs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path update' -r -s D -l destination -d 'destination directory'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path update'  -s n -l dry-run -d 'dry run'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path update'   -l follow -d 'follow symlinks'
@@ -396,6 +432,7 @@ complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path upgrade' -r -s o
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path upgrade' -r -s r -l repo -d 'set repo'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path upgrade' -r  -l color -d 'colorize diffs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path upgrade' -r -s c -l config -d 'config file'
+complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path upgrade'   -l debug -d 'write debug logs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path upgrade' -r -s D -l destination -d 'destination directory'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path upgrade'  -s n -l dry-run -d 'dry run'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path upgrade'   -l follow -d 'follow symlinks'
@@ -404,6 +441,7 @@ complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path upgrade' -r -s S
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path upgrade'  -s v -l verbose -d 'verbose'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path verify' -r  -l color -d 'colorize diffs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path verify' -r -s c -l config -d 'config file'
+complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path verify'   -l debug -d 'write debug logs'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path verify' -r -s D -l destination -d 'destination directory'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path verify'  -s n -l dry-run -d 'dry run'
 complete -c chezmoi -f -n '__fish_chezmoi_seen_subcommand_path verify'   -l follow -d 'follow symlinks'
diff --git a/internal/chezmoi/anymutator.go b/internal/chezmoi/anymutator.go
index cc7eb120f..512cb4929 100644
--- a/internal/chezmoi/anymutator.go
+++ b/internal/chezmoi/anymutator.go
@@ -1,6 +1,9 @@
 package chezmoi
 
-import "os"
+import (
+	"os"
+	"os/exec"
+)
 
 // An AnyMutator wraps another Mutator and records if any of its mutating
 // methods are called.
@@ -23,6 +26,11 @@ func (m *AnyMutator) Chmod(name string, mode os.FileMode) error {
 	return m.m.Chmod(name, mode)
 }
 
+// IdempotentCmdOutput implements Mutator.IdempotentCmdOutput.
+func (m *AnyMutator) IdempotentCmdOutput(cmd *exec.Cmd) ([]byte, error) {
+	return m.m.IdempotentCmdOutput(cmd)
+}
+
 // Mkdir implements Mutator.Mkdir.
 func (m *AnyMutator) Mkdir(name string, perm os.FileMode) error {
 	m.mutated = true
@@ -46,6 +54,12 @@ func (m *AnyMutator) Rename(oldpath, newpath string) error {
 	return m.m.Rename(oldpath, newpath)
 }
 
+// RunCmd implements Mutator.RunCmd.
+func (m *AnyMutator) RunCmd(cmd *exec.Cmd) error {
+	m.mutated = true
+	return m.m.RunCmd(cmd)
+}
+
 // Stat implements Mutator.Stat.
 func (m *AnyMutator) Stat(path string) (os.FileInfo, error) {
 	return m.m.Stat(path)
diff --git a/internal/chezmoi/debugmutator.go b/internal/chezmoi/debugmutator.go
new file mode 100644
index 000000000..280066563
--- /dev/null
+++ b/internal/chezmoi/debugmutator.go
@@ -0,0 +1,118 @@
+package chezmoi
+
+import (
+	"log"
+	"os"
+	"os/exec"
+	"time"
+)
+
+// A DebugMutator wraps a Mutator and logs all of the actions it executes.
+type DebugMutator struct {
+	m Mutator
+}
+
+// NewDebugMutator returns a new DebugMutator.
+func NewDebugMutator(m Mutator) *DebugMutator {
+	return &DebugMutator{
+		m: m,
+	}
+}
+
+// Chmod implements Mutator.Chmod.
+func (m *DebugMutator) Chmod(name string, mode os.FileMode) error {
+	return Debugf("Chmod(%q, 0%o)", []interface{}{name, mode}, func() error {
+		return m.m.Chmod(name, mode)
+	})
+}
+
+// IdempotentCmdOutput implements Mutator.IdempotentCmdOutput.
+func (m *DebugMutator) IdempotentCmdOutput(cmd *exec.Cmd) ([]byte, error) {
+	var output []byte
+	err := Debugf("IdempotentCmdOutput(%v)", []interface{}{cmd}, func() error {
+		var err error
+		output, err = m.m.IdempotentCmdOutput(cmd)
+		return err
+	})
+	return output, err
+}
+
+// Mkdir implements Mutator.Mkdir.
+func (m *DebugMutator) Mkdir(name string, perm os.FileMode) error {
+	return Debugf("Mkdir(%q, 0%o)", []interface{}{name, perm}, func() error {
+		return m.m.Mkdir(name, perm)
+	})
+}
+
+// RemoveAll implements Mutator.RemoveAll.
+func (m *DebugMutator) RemoveAll(name string) error {
+	return Debugf("RemoveAll(%q)", []interface{}{name}, func() error {
+		return m.m.RemoveAll(name)
+	})
+}
+
+// Rename implements Mutator.Rename.
+func (m *DebugMutator) Rename(oldpath, newpath string) error {
+	return Debugf("Rename(%q, %q)", []interface{}{oldpath, newpath}, func() error {
+		return m.Rename(oldpath, newpath)
+	})
+}
+
+// RunCmd implements Mutator.RunCmd.
+func (m *DebugMutator) RunCmd(cmd *exec.Cmd) error {
+	return Debugf("Run(%v)", []interface{}{cmd}, func() error {
+		return m.m.RunCmd(cmd)
+	})
+}
+
+// Stat implements Mutator.Stat.
+func (m *DebugMutator) Stat(name string) (os.FileInfo, error) {
+	var fi os.FileInfo
+	err := Debugf("Stat(%q)", []interface{}{name}, func() error {
+		var err error
+		fi, err = m.m.Stat(name)
+		return err
+	})
+	return fi, err
+}
+
+// WriteFile implements Mutator.WriteFile.
+func (m *DebugMutator) WriteFile(name string, data []byte, perm os.FileMode, currData []byte) error {
+	return Debugf("WriteFile(%q, _, 0%o, _)", []interface{}{name, perm}, func() error {
+		return m.m.WriteFile(name, data, perm, currData)
+	})
+}
+
+// WriteSymlink implements Mutator.WriteSymlink.
+func (m *DebugMutator) WriteSymlink(oldname, newname string) error {
+	return Debugf("WriteSymlink(%q, %q)", []interface{}{oldname, newname}, func() error {
+		return m.m.WriteSymlink(oldname, newname)
+	})
+}
+
+// Debugf logs debugging information about calling f.
+func Debugf(format string, args []interface{}, f func() error) error {
+	errChan := make(chan error)
+	start := time.Now()
+	go func(errChan chan<- error) {
+		errChan <- f()
+	}(errChan)
+	select {
+	case err := <-errChan:
+		if err == nil {
+			log.Printf(format+" (%s)", append(args, time.Since(start))...)
+		} else {
+			log.Printf(format+" == %v (%s)", append(args, err, time.Since(start)))
+		}
+		return err
+	case <-time.After(1 * time.Second):
+		log.Printf(format, args...)
+		err := <-errChan
+		if err == nil {
+			log.Printf(format+" (%s)", append(args, time.Since(start))...)
+		} else {
+			log.Printf(format+" == %v (%s)", append(args, err, time.Since(start)))
+		}
+		return err
+	}
+}
diff --git a/internal/chezmoi/fsmutator.go b/internal/chezmoi/fsmutator.go
index a0300aecc..71774036d 100644
--- a/internal/chezmoi/fsmutator.go
+++ b/internal/chezmoi/fsmutator.go
@@ -2,12 +2,13 @@ package chezmoi
 
 import (
 	"os"
+	"os/exec"
 
 	"github.com/google/renameio"
 	vfs "github.com/twpayne/go-vfs"
 )
 
-// An FSMutator makes changes to an vfs.FS.
+// An FSMutator makes changes to a vfs.FS.
 type FSMutator struct {
 	vfs.FS
 	devCache     map[string]uint // devCache maps directories to device numbers.
@@ -23,14 +24,24 @@ func NewFSMutator(fs vfs.FS) *FSMutator {
 	}
 }
 
+// IdempotentCmdOutput implements Mutator.IdempotentCmdOutput.
+func (m *FSMutator) IdempotentCmdOutput(cmd *exec.Cmd) ([]byte, error) {
+	return cmd.Output()
+}
+
+// RunCmd implements Mutator.RunCmd.
+func (m *FSMutator) RunCmd(cmd *exec.Cmd) error {
+	return cmd.Run()
+}
+
 // WriteSymlink implements Mutator.WriteSymlink.
-func (a *FSMutator) WriteSymlink(oldname, newname string) error {
+func (m *FSMutator) WriteSymlink(oldname, newname string) error {
 	// Special case: if writing to the real filesystem, use github.com/google/renameio
-	if a.FS == vfs.OSFS {
+	if m.FS == vfs.OSFS {
 		return renameio.Symlink(oldname, newname)
 	}
-	if err := a.FS.RemoveAll(newname); err != nil && !os.IsNotExist(err) {
+	if err := m.FS.RemoveAll(newname); err != nil && !os.IsNotExist(err) {
 		return err
 	}
-	return a.FS.Symlink(oldname, newname)
+	return m.FS.Symlink(oldname, newname)
 }
diff --git a/internal/chezmoi/fsmutator_posix.go b/internal/chezmoi/fsmutator_posix.go
index 21403574b..5b398b517 100644
--- a/internal/chezmoi/fsmutator_posix.go
+++ b/internal/chezmoi/fsmutator_posix.go
@@ -13,13 +13,13 @@ import (
 )
 
 // WriteFile implements Mutator.WriteFile.
-func (a *FSMutator) WriteFile(name string, data []byte, perm os.FileMode, currData []byte) error {
+func (m *FSMutator) WriteFile(name string, data []byte, perm os.FileMode, currData []byte) error {
 	// Special case: if writing to the real filesystem, use github.com/google/renameio
-	if a.FS == vfs.OSFS {
+	if m.FS == vfs.OSFS {
 		dir := filepath.Dir(name)
-		dev, ok := a.devCache[dir]
+		dev, ok := m.devCache[dir]
 		if !ok {
-			info, err := a.Stat(dir)
+			info, err := m.Stat(dir)
 			if err != nil {
 				return err
 			}
@@ -28,12 +28,12 @@ func (a *FSMutator) WriteFile(name string, data []byte, perm os.FileMode, currDa
 				return errors.New("os.FileInfo.Sys() cannot be converted to a *syscall.Stat_t")
 			}
 			dev = uint(statT.Dev)
-			a.devCache[dir] = dev
+			m.devCache[dir] = dev
 		}
-		tempDir, ok := a.tempDirCache[dev]
+		tempDir, ok := m.tempDirCache[dev]
 		if !ok {
 			tempDir = renameio.TempDir(dir)
-			a.tempDirCache[dev] = tempDir
+			m.tempDirCache[dev] = tempDir
 		}
 		t, err := renameio.TempFile(tempDir, name)
 		if err != nil {
@@ -50,5 +50,5 @@ func (a *FSMutator) WriteFile(name string, data []byte, perm os.FileMode, currDa
 		}
 		return t.CloseAtomicallyReplace()
 	}
-	return a.FS.WriteFile(name, data, perm)
+	return m.FS.WriteFile(name, data, perm)
 }
diff --git a/internal/chezmoi/fsmutator_windows.go b/internal/chezmoi/fsmutator_windows.go
index 87fdb93e6..82a919bbe 100644
--- a/internal/chezmoi/fsmutator_windows.go
+++ b/internal/chezmoi/fsmutator_windows.go
@@ -7,6 +7,6 @@ import (
 )
 
 // WriteFile implements Mutator.WriteFile.
-func (a *FSMutator) WriteFile(name string, data []byte, perm os.FileMode, currData []byte) error {
-	return a.FS.WriteFile(name, data, perm)
+func (m *FSMutator) WriteFile(name string, data []byte, perm os.FileMode, currData []byte) error {
+	return m.FS.WriteFile(name, data, perm)
 }
diff --git a/internal/chezmoi/mutator.go b/internal/chezmoi/mutator.go
index 71ebd12ea..9ead0eb04 100644
--- a/internal/chezmoi/mutator.go
+++ b/internal/chezmoi/mutator.go
@@ -1,13 +1,18 @@
 package chezmoi
 
-import "os"
+import (
+	"os"
+	"os/exec"
+)
 
 // A Mutator makes changes.
 type Mutator interface {
 	Chmod(name string, mode os.FileMode) error
+	IdempotentCmdOutput(cmd *exec.Cmd) ([]byte, error)
 	Mkdir(name string, perm os.FileMode) error
 	RemoveAll(name string) error
 	Rename(oldpath, newpath string) error
+	RunCmd(cmd *exec.Cmd) error
 	Stat(name string) (os.FileInfo, error)
 	WriteFile(filename string, data []byte, perm os.FileMode, currData []byte) error
 	WriteSymlink(oldname, newname string) error
diff --git a/internal/chezmoi/nullmutator.go b/internal/chezmoi/nullmutator.go
index c79dfcf23..c43aff449 100644
--- a/internal/chezmoi/nullmutator.go
+++ b/internal/chezmoi/nullmutator.go
@@ -1,6 +1,9 @@
 package chezmoi
 
-import "os"
+import (
+	"os"
+	"os/exec"
+)
 
 // NullMutator is an Mutator that does nothing.
 type NullMutator struct{}
@@ -10,6 +13,11 @@ func (NullMutator) Chmod(string, os.FileMode) error {
 	return nil
 }
 
+// IdempotentCmdOutput implements Mutator.IdempotentCmdOutput.
+func (NullMutator) IdempotentCmdOutput(cmd *exec.Cmd) ([]byte, error) {
+	return cmd.Output()
+}
+
 // Mkdir implements Mutator.Mkdir.
 func (NullMutator) Mkdir(string, os.FileMode) error {
 	return nil
@@ -25,6 +33,11 @@ func (NullMutator) Rename(string, string) error {
 	return nil
 }
 
+// RunCmd implements Mutator.RunCmd.
+func (NullMutator) RunCmd(cmd *exec.Cmd) error {
+	return nil
+}
+
 // Stat implements Mutator.Stat.
 func (NullMutator) Stat(path string) (os.FileInfo, error) {
 	return nil, &os.PathError{
diff --git a/internal/chezmoi/loggingmutator.go b/internal/chezmoi/verbosemutator.go
similarity index 66%
rename from internal/chezmoi/loggingmutator.go
rename to internal/chezmoi/verbosemutator.go
index 1449ee89b..fac9317c3 100644
--- a/internal/chezmoi/loggingmutator.go
+++ b/internal/chezmoi/verbosemutator.go
@@ -8,23 +8,24 @@ import (
 	"io"
 	"net/http"
 	"os"
+	"os/exec"
 	"path/filepath"
 	"strings"
 
 	"github.com/pkg/diff"
 )
 
-// A LoggingMutator wraps an Mutator and logs all of the actions it executes
-// and any errors.
-type LoggingMutator struct {
+// A VerboseMutator wraps an Mutator and logs all of the actions it executes and
+// any errors as pseudo shell commands.
+type VerboseMutator struct {
 	m       Mutator
 	w       io.Writer
 	colored bool
 }
 
-// NewLoggingMutator returns a new LoggingMutator.
-func NewLoggingMutator(w io.Writer, m Mutator, colored bool) *LoggingMutator {
-	return &LoggingMutator{
+// NewVerboseMutator returns a new VerboseMutator.
+func NewVerboseMutator(w io.Writer, m Mutator, colored bool) *VerboseMutator {
+	return &VerboseMutator{
 		m:       m,
 		w:       w,
 		colored: colored,
@@ -32,7 +33,7 @@ func NewLoggingMutator(w io.Writer, m Mutator, colored bool) *LoggingMutator {
 }
 
 // Chmod implements Mutator.Chmod.
-func (m *LoggingMutator) Chmod(name string, mode os.FileMode) error {
+func (m *VerboseMutator) Chmod(name string, mode os.FileMode) error {
 	action := fmt.Sprintf("chmod %o %s", mode, name)
 	err := m.m.Chmod(name, mode)
 	if err == nil {
@@ -43,8 +44,20 @@ func (m *LoggingMutator) Chmod(name string, mode os.FileMode) error {
 	return err
 }
 
+// IdempotentCmdOutput implements Mutator.IdempotentCmdOutput.
+func (m *VerboseMutator) IdempotentCmdOutput(cmd *exec.Cmd) ([]byte, error) {
+	action := fmt.Sprintf("run %s", cmd.String())
+	output, err := m.m.IdempotentCmdOutput(cmd)
+	if err == nil {
+		_, _ = fmt.Fprintln(m.w, action)
+	} else {
+		_, _ = fmt.Fprintf(m.w, "%s: %v\n", action, err)
+	}
+	return output, err
+}
+
 // Mkdir implements Mutator.Mkdir.
-func (m *LoggingMutator) Mkdir(name string, perm os.FileMode) error {
+func (m *VerboseMutator) Mkdir(name string, perm os.FileMode) error {
 	action := fmt.Sprintf("mkdir -m %o %s", perm, name)
 	err := m.m.Mkdir(name, perm)
 	if err == nil {
@@ -56,7 +69,7 @@ func (m *LoggingMutator) Mkdir(name string, perm os.FileMode) error {
 }
 
 // RemoveAll implements Mutator.RemoveAll.
-func (m *LoggingMutator) RemoveAll(name string) error {
+func (m *VerboseMutator) RemoveAll(name string) error {
 	action := fmt.Sprintf("rm -rf %s", name)
 	err := m.m.RemoveAll(name)
 	if err == nil {
@@ -68,7 +81,7 @@ func (m *LoggingMutator) RemoveAll(name string) error {
 }
 
 // Rename implements Mutator.Rename.
-func (m *LoggingMutator) Rename(oldpath, newpath string) error {
+func (m *VerboseMutator) Rename(oldpath, newpath string) error {
 	action := fmt.Sprintf("mv %s %s", oldpath, newpath)
 	err := m.m.Rename(oldpath, newpath)
 	if err == nil {
@@ -79,13 +92,25 @@ func (m *LoggingMutator) Rename(oldpath, newpath string) error {
 	return err
 }
 
+// RunCmd implements Mutator.RunCmd.
+func (m *VerboseMutator) RunCmd(cmd *exec.Cmd) error {
+	action := fmt.Sprintf("run %s", cmd.String())
+	err := m.m.RunCmd(cmd)
+	if err == nil {
+		_, _ = fmt.Fprintln(m.w, action)
+	} else {
+		_, _ = fmt.Fprintf(m.w, "%s: %v\n", action, err)
+	}
+	return err
+}
+
 // Stat implements Mutator.Stat.
-func (m *LoggingMutator) Stat(name string) (os.FileInfo, error) {
+func (m *VerboseMutator) Stat(name string) (os.FileInfo, error) {
 	return m.m.Stat(name)
 }
 
 // WriteFile implements Mutator.WriteFile.
-func (m *LoggingMutator) WriteFile(name string, data []byte, perm os.FileMode, currData []byte) error {
+func (m *VerboseMutator) WriteFile(name string, data []byte, perm os.FileMode, currData []byte) error {
 	action := fmt.Sprintf("install -m %o /dev/null %s", perm, name)
 	err := m.m.WriteFile(name, data, perm, currData)
 	if err == nil {
@@ -121,7 +146,7 @@ func (m *LoggingMutator) WriteFile(name string, data []byte, perm os.FileMode, c
 }
 
 // WriteSymlink implements Mutator.WriteSymlink.
-func (m *LoggingMutator) WriteSymlink(oldname, newname string) error {
+func (m *VerboseMutator) WriteSymlink(oldname, newname string) error {
 	action := fmt.Sprintf("ln -sf %s %s", oldname, newname)
 	err := m.m.WriteSymlink(oldname, newname)
 	if err == nil {


Test patch:
diff --git a/cmd/add_test.go b/cmd/add_test.go
index 415ca5665..36afc908b 100644
--- a/cmd/add_test.go
+++ b/cmd/add_test.go
@@ -1,6 +1,7 @@
 package cmd
 
 import (
+	"os"
 	"path/filepath"
 	"testing"
 
@@ -11,13 +12,6 @@ import (
 )
 
 func TestAddAfterModification(t *testing.T) {
-	c := &Config{
-		SourceDir: "/home/user/.chezmoi",
-		DestDir:   "/home/user",
-		Umask:     022,
-		DryRun:    false,
-		Verbose:   true,
-	}
 	fs, cleanup, err := vfst.NewTestFS(map[string]interface{}{
 		"/home/user":          &vfst.Dir{Perm: 0755},
 		"/home/user/.chezmoi": &vfst.Dir{Perm: 0700},
@@ -25,8 +19,15 @@ func TestAddAfterModification(t *testing.T) {
 	})
 	require.NoError(t, err)
 	defer cleanup()
+	c := &Config{
+		fs:        fs,
+		mutator:   chezmoi.NewVerboseMutator(os.Stdout, chezmoi.NewFSMutator(fs), false),
+		SourceDir: "/home/user/.chezmoi",
+		DestDir:   "/home/user",
+		Umask:     022,
+	}
 	args := []string{"/home/user/.bashrc"}
-	assert.NoError(t, c.runAddCmd(fs, args))
+	assert.NoError(t, c.runAddCmd(nil, args))
 	vfst.RunTests(t, fs, "",
 		vfst.TestPath("/home/user/.chezmoi/dot_bashrc",
 			vfst.TestModeIsRegular,
@@ -34,7 +35,7 @@ func TestAddAfterModification(t *testing.T) {
 		),
 	)
 	assert.NoError(t, fs.WriteFile("/home/user/.bashrc", []byte("# new contents of .bashrc\n"), 0644))
-	assert.NoError(t, c.runAddCmd(fs, args))
+	assert.NoError(t, c.runAddCmd(nil, args))
 	vfst.RunTests(t, fs, "",
 		vfst.TestPath("/home/user/.chezmoi/dot_bashrc",
 			vfst.TestModeIsRegular,
@@ -414,13 +415,16 @@ func TestAddCommand(t *testing.T) {
 		},
 	} {
 		t.Run(tc.name, func(t *testing.T) {
+			fs, cleanup, err := vfst.NewTestFS(tc.root)
+			require.NoError(t, err)
+			defer cleanup()
 			c := &Config{
+				fs:        fs,
+				mutator:   chezmoi.NewVerboseMutator(os.Stdout, chezmoi.NewFSMutator(fs), false),
 				SourceDir: "/home/user/.chezmoi",
 				DestDir:   "/home/user",
 				Follow:    tc.follow,
 				Umask:     022,
-				DryRun:    false,
-				Verbose:   true,
 				SourceVCS: sourceVCSConfig{
 					Command: "git",
 				},
@@ -430,10 +434,7 @@ func TestAddCommand(t *testing.T) {
 				},
 				add: tc.add,
 			}
-			fs, cleanup, err := vfst.NewTestFS(tc.root)
-			require.NoError(t, err)
-			defer cleanup()
-			assert.NoError(t, c.runAddCmd(fs, tc.args))
+			assert.NoError(t, c.runAddCmd(nil, tc.args))
 			vfst.RunTests(t, fs, "", tc.tests)
 		})
 	}
@@ -454,16 +455,17 @@ func TestIssue192(t *testing.T) {
 			"/home/offbyone": &vfst.Symlink{Target: "/local/home/offbyone/"},
 		},
 	}
+	fs, cleanup, err := vfst.NewTestFS(root)
+	require.NoError(t, err)
+	defer cleanup()
 	c := &Config{
+		fs:        fs,
+		mutator:   chezmoi.NewVerboseMutator(os.Stdout, chezmoi.NewFSMutator(fs), false),
 		SourceDir: "/home/offbyone/.local/share/chezmoi",
 		DestDir:   "/home/offbyone",
 		Umask:     022,
-		Verbose:   true,
 	}
-	fs, cleanup, err := vfst.NewTestFS(root)
-	require.NoError(t, err)
-	defer cleanup()
-	assert.NoError(t, c.runAddCmd(fs, []string{"/home/offbyone/snoop/.list"}))
+	assert.NoError(t, c.runAddCmd(nil, []string{"/home/offbyone/snoop/.list"}))
 	vfst.RunTests(t, fs, "",
 		vfst.TestPath("/local/home/offbyone/.local/share/chezmoi/snoop/dot_list",
 			vfst.TestModeIsRegular,
diff --git a/cmd/apply_test.go b/cmd/apply_test.go
index 6814b5baa..773efd443 100644
--- a/cmd/apply_test.go
+++ b/cmd/apply_test.go
@@ -8,6 +8,7 @@ import (
 
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
+	"github.com/twpayne/chezmoi/internal/chezmoi"
 	vfs "github.com/twpayne/go-vfs"
 	"github.com/twpayne/go-vfs/vfst"
 )
@@ -140,12 +141,14 @@ func TestApplyCommand(t *testing.T) {
 			require.NoError(t, err)
 			defer cleanup()
 			c := &Config{
+				fs:        fs,
+				mutator:   chezmoi.NewVerboseMutator(os.Stdout, chezmoi.NewFSMutator(fs), false),
 				SourceDir: "/home/user/.local/share/chezmoi",
 				DestDir:   "/home/user",
 				Umask:     022,
 				bds:       newTestBaseDirectorySpecification("/home/user"),
 			}
-			assert.NoError(t, c.runApplyCmd(fs, nil))
+			assert.NoError(t, c.runApplyCmd(nil, nil))
 			vfst.RunTests(t, fs, "",
 				vfst.TestPath("/home/user/dir",
 					vfst.TestIsDir,
@@ -228,13 +231,15 @@ func TestApplyFollow(t *testing.T) {
 			require.NoError(t, err)
 			defer cleanup()
 			c := &Config{
+				fs:        fs,
+				mutator:   chezmoi.NewVerboseMutator(os.Stdout, chezmoi.NewFSMutator(fs), false),
 				SourceDir: "/home/user/.local/share/chezmoi",
 				DestDir:   "/home/user",
 				Follow:    tc.follow,
 				Umask:     022,
 				bds:       newTestBaseDirectorySpecification("/home/user"),
 			}
-			assert.NoError(t, c.runApplyCmd(fs, nil))
+			assert.NoError(t, c.runApplyCmd(nil, nil))
 			vfst.RunTests(t, fs, "", tc.tests)
 		})
 	}
@@ -346,6 +351,8 @@ func TestApplyRemove(t *testing.T) {
 			require.NoError(t, err)
 			defer cleanup()
 			c := &Config{
+				fs:        fs,
+				mutator:   chezmoi.NewVerboseMutator(os.Stdout, chezmoi.NewFSMutator(fs), false),
 				SourceDir: "/home/user/.local/share/chezmoi",
 				DestDir:   "/home/user",
 				Data:      tc.data,
@@ -353,7 +360,7 @@ func TestApplyRemove(t *testing.T) {
 				Umask:     022,
 				bds:       newTestBaseDirectorySpecification("/home/user"),
 			}
-			assert.NoError(t, c.runApplyCmd(fs, nil))
+			assert.NoError(t, c.runApplyCmd(nil, nil))
 			vfst.RunTests(t, fs, "", tc.tests)
 		})
 	}
@@ -375,6 +382,8 @@ func TestApplyScript(t *testing.T) {
 			}()
 			apply := func() {
 				c := &Config{
+					fs:                fs,
+					mutator:           chezmoi.NewVerboseMutator(os.Stdout, chezmoi.NewFSMutator(fs), false),
 					SourceDir:         "/home/user/.local/share/chezmoi",
 					DestDir:           "/",
 					Umask:             022,
@@ -382,7 +391,7 @@ func TestApplyScript(t *testing.T) {
 					bds:               newTestBaseDirectorySpecification("/home/user"),
 					scriptStateBucket: []byte("script"),
 				}
-				require.NoError(t, c.runApplyCmd(fs, nil))
+				require.NoError(t, c.runApplyCmd(nil, nil))
 			}
 			// Run apply three times. As chezmoi should be idempotent, the
 			// result should be the same each time.
@@ -411,6 +420,8 @@ func TestApplyRunOnce(t *testing.T) {
 	defer cleanup()
 
 	c := &Config{
+		fs:        fs,
+		mutator:   chezmoi.NewVerboseMutator(os.Stdout, chezmoi.NewFSMutator(fs), false),
 		SourceDir: "/home/user/.local/share/chezmoi",
 		DestDir:   "/",
 		Umask:     022,
@@ -421,7 +432,7 @@ func TestApplyRunOnce(t *testing.T) {
 		scriptStateBucket: []byte("script"),
 	}
 
-	require.NoError(t, c.runApplyCmd(fs, nil))
+	require.NoError(t, c.runApplyCmd(nil, nil))
 	vfst.RunTests(t, fs, "",
 		vfst.TestPath("/home/user/.config/chezmoi/chezmoistate.boltdb",
 			vfst.TestModeIsRegular,
@@ -432,7 +443,7 @@ func TestApplyRunOnce(t *testing.T) {
 	require.NoError(t, err)
 	assert.Equal(t, []byte("bar\n"), actualData)
 
-	require.NoError(t, c.runApplyCmd(fs, nil))
+	require.NoError(t, c.runApplyCmd(nil, nil))
 	actualData, err = ioutil.ReadFile(tempFile)
 	require.NoError(t, err)
 	assert.Equal(t, []byte("bar\n"), actualData)
diff --git a/cmd/archive_test.go b/cmd/archive_test.go
index 0aac1f9e4..8d654bee4 100644
--- a/cmd/archive_test.go
+++ b/cmd/archive_test.go
@@ -5,11 +5,13 @@ import (
 	"bytes"
 	"io"
 	"io/ioutil"
+	"os"
 	"path/filepath"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
+	"github.com/twpayne/chezmoi/internal/chezmoi"
 	"github.com/twpayne/go-vfs/vfst"
 )
 
@@ -22,11 +24,13 @@ func TestArchiveCmd(t *testing.T) {
 	defer cleanup()
 	stdout := &bytes.Buffer{}
 	c := &Config{
+		fs:        fs,
+		mutator:   chezmoi.NewVerboseMutator(os.Stdout, chezmoi.NewFSMutator(fs), false),
 		SourceDir: "/home/user/.local/share/chezmoi",
 		Umask:     022,
 		stdout:    stdout,
 	}
-	assert.NoError(t, c.runArchiveCmd(fs, nil))
+	assert.NoError(t, c.runArchiveCmd(nil, nil))
 	r := tar.NewReader(stdout)
 
 	h, err := r.Next()
diff --git a/cmd/chattr_test.go b/cmd/chattr_test.go
index 1cbf80caa..854520404 100644
--- a/cmd/chattr_test.go
+++ b/cmd/chattr_test.go
@@ -1,10 +1,12 @@
 package cmd
 
 import (
+	"os"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
+	"github.com/twpayne/chezmoi/internal/chezmoi"
 	"github.com/twpayne/go-vfs/vfst"
 )
 
@@ -285,16 +287,17 @@ func TestChattrCommand(t *testing.T) {
 		},
 	} {
 		t.Run(tc.name, func(t *testing.T) {
+			fs, cleanup, err := vfst.NewTestFS(tc.root)
+			require.NoError(t, err)
+			defer cleanup()
 			c := &Config{
+				fs:        fs,
+				mutator:   chezmoi.NewVerboseMutator(os.Stdout, chezmoi.NewFSMutator(fs), false),
 				SourceDir: "/home/user/.config/share/chezmoi",
 				DestDir:   "/home/user",
 				Umask:     022,
-				Verbose:   true,
 			}
-			fs, cleanup, err := vfst.NewTestFS(tc.root)
-			require.NoError(t, err)
-			defer cleanup()
-			assert.NoError(t, c.runChattrCmd(fs, tc.args))
+			assert.NoError(t, c.runChattrCmd(nil, tc.args))
 			vfst.RunTests(t, fs, "", tc.tests)
 		})
 	}
diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go
index d3cbd39d9..70cd017b9 100644
--- a/cmd/cmd_test.go
+++ b/cmd/cmd_test.go
@@ -6,6 +6,7 @@ import (
 
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
+	"github.com/twpayne/chezmoi/internal/chezmoi"
 	"github.com/twpayne/go-vfs/vfst"
 )
 
@@ -18,6 +19,8 @@ func TestExercise(t *testing.T) {
 	defer cleanup()
 
 	c := &Config{
+		fs:        fs,
+		mutator:   chezmoi.NewVerboseMutator(os.Stdout, chezmoi.NewFSMutator(fs), false),
 		SourceDir: "/home/user/.chezmoi",
 		DestDir:   "/home/user",
 		Umask:     022,
@@ -34,7 +37,7 @@ func TestExercise(t *testing.T) {
 
 	// chezmoi add ~/.bashrc
 	t.Run("chezmoi_add_bashrc", func(t *testing.T) {
-		assert.NoError(t, c.runAddCmd(fs, []string{"/home/user/.bashrc"}))
+		assert.NoError(t, c.runAddCmd(nil, []string{"/home/user/.bashrc"}))
 		vfst.RunTests(t, fs, "",
 			vfst.TestPath("/home/user/.chezmoi",
 				vfst.TestIsDir,
@@ -50,7 +53,7 @@ func TestExercise(t *testing.T) {
 
 	// chezmoi forget ~/.bashrc
 	t.Run("chezmoi_forget_bashrc", func(t *testing.T) {
-		assert.NoError(t, c.runForgetCmd(fs, []string{"/home/user/.bashrc"}))
+		assert.NoError(t, c.runForgetCmd(nil, []string{"/home/user/.bashrc"}))
 		vfst.RunTests(t, fs, "",
 			vfst.TestPath("/home/user/.chezmoi/dot_bashrc",
 				vfst.TestDoesNotExist,
@@ -61,7 +64,7 @@ func TestExercise(t *testing.T) {
 	// chezmoi add ~/.netrc
 	t.Run("chezmoi_add_netrc", func(t *testing.T) {
 		mustWriteFile("/home/user/.netrc", "# contents of .netrc\n", 0600)
-		assert.NoError(t, c.runAddCmd(fs, []string{"/home/user/.netrc"}))
+		assert.NoError(t, c.runAddCmd(nil, []string{"/home/user/.netrc"}))
 		vfst.RunTests(t, fs, "",
 			vfst.TestPath("/home/user/.chezmoi/private_dot_netrc",
 				vfst.TestModeIsRegular,
@@ -73,7 +76,7 @@ func TestExercise(t *testing.T) {
 
 	// chezmoi chattr -- -private,+empty ~/.netrc
 	t.Run("chezmoi_chattr_netrc", func(t *testing.T) {
-		assert.NoError(t, c.runChattrCmd(fs, []string{"-private,+empty", "/home/user/.netrc"}))
+		assert.NoError(t, c.runChattrCmd(nil, []string{"-private,+empty", "/home/user/.netrc"}))
 		vfst.RunTests(t, fs, "",
 			vfst.TestPath("/home/user/.chezmoi/empty_dot_netrc",
 				vfst.TestModeIsRegular,
@@ -85,7 +88,7 @@ func TestExercise(t *testing.T) {
 
 	// chezmoi apply ~/.netrc
 	t.Run("chezmoi_apply_netrc", func(t *testing.T) {
-		assert.NoError(t, c.runApplyCmd(fs, []string{"/home/user/.netrc"}))
+		assert.NoError(t, c.runApplyCmd(nil, []string{"/home/user/.netrc"}))
 		vfst.RunTests(t, fs, "",
 			vfst.TestPath("/home/user/.netrc",
 				vfst.TestModeIsRegular,
@@ -97,7 +100,7 @@ func TestExercise(t *testing.T) {
 
 	// chezmoi remove ~/.netrc
 	t.Run("chezmoi_remove_netrc", func(t *testing.T) {
-		assert.NoError(t, c.runRemoveCmd(fs, []string{"/home/user/.netrc"}))
+		assert.NoError(t, c.runRemoveCmd(nil, []string{"/home/user/.netrc"}))
 		vfst.RunTests(t, fs, "",
 			vfst.TestPath("/home/user/.netrc",
 				vfst.TestDoesNotExist,
diff --git a/cmd/diff_posix_test.go b/cmd/diff_posix_test.go
index a82f979cd..358aa6589 100644
--- a/cmd/diff_posix_test.go
+++ b/cmd/diff_posix_test.go
@@ -10,6 +10,7 @@ import (
 
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
+	"github.com/twpayne/chezmoi/internal/chezmoi"
 	vfs "github.com/twpayne/go-vfs"
 	"github.com/twpayne/go-vfs/vfst"
 )
@@ -28,12 +29,14 @@ func TestDiffDoesNotRunScript(t *testing.T) {
 		},
 	))
 	c := &Config{
+		fs:        fs,
+		mutator:   chezmoi.NewVerboseMutator(os.Stdout, chezmoi.NewFSMutator(fs), false),
 		SourceDir: "/home/user/.local/share/chezmoi",
 		DestDir:   "/",
 		Umask:     022,
 		bds:       newTestBaseDirectorySpecification("/home/user"),
 	}
-	assert.NoError(t, c.runDiffCmd(fs, nil))
+	assert.NoError(t, c.runDiffCmd(nil, nil))
 	vfst.RunTests(t, vfs.OSFS, "",
 		vfst.TestPath(filepath.Join(tempDir, "evidence"),
 			vfst.TestDoesNotExist,
diff --git a/cmd/dump_test.go b/cmd/dump_test.go
index 6e1639df4..171c383e9 100644
--- a/cmd/dump_test.go
+++ b/cmd/dump_test.go
@@ -4,11 +4,13 @@ import (
 	"bytes"
 	"encoding/json"
 	"fmt"
+	"os"
 	"path/filepath"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
+	"github.com/twpayne/chezmoi/internal/chezmoi"
 	"github.com/twpayne/go-vfs/vfst"
 )
 
@@ -21,6 +23,8 @@ func TestDumpCmd(t *testing.T) {
 	defer cleanup()
 	stdout := &bytes.Buffer{}
 	c := &Config{
+		fs:        fs,
+		mutator:   chezmoi.NewVerboseMutator(os.Stdout, chezmoi.NewFSMutator(fs), false),
 		SourceDir: "/home/user/.local/share/chezmoi",
 		Umask:     022,
 		dump: dumpCmdConfig{
@@ -29,7 +33,7 @@ func TestDumpCmd(t *testing.T) {
 		},
 		stdout: stdout,
 	}
-	assert.NoError(t, c.runDumpCmd(fs, nil))
+	assert.NoError(t, c.runDumpCmd(nil, nil))
 	fmt.Println(stdout.String())
 	var actual interface{}
 	assert.NoError(t, json.NewDecoder(stdout).Decode(&actual))
diff --git a/cmd/import_test.go b/cmd/import_test.go
index 80d7535b9..c3e483e2c 100644
--- a/cmd/import_test.go
+++ b/cmd/import_test.go
@@ -3,10 +3,12 @@ package cmd
 import (
 	"archive/tar"
 	"bytes"
+	"os"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
+	"github.com/twpayne/chezmoi/internal/chezmoi"
 	"github.com/twpayne/go-vfs/vfst"
 )
 
@@ -40,10 +42,12 @@ func TestImportCmd(t *testing.T) {
 	defer cleanup()
 
 	c := &Config{
+		fs:        fs,
+		mutator:   chezmoi.NewVerboseMutator(os.Stdout, chezmoi.NewFSMutator(fs), false),
 		SourceDir: "/home/user/.local/share/chezmoi",
 		stdin:     b,
 	}
-	assert.NoError(t, c.runImportCmd(fs, nil))
+	assert.NoError(t, c.runImportCmd(nil, nil))
 
 	vfst.RunTests(t, fs, "test",
 		vfst.TestPath("/home/user/.local/share/chezmoi/dir",
diff --git a/cmd/init_test.go b/cmd/init_test.go
index 77ed763f0..3d9f5eedc 100644
--- a/cmd/init_test.go
+++ b/cmd/init_test.go
@@ -29,13 +29,15 @@ func TestCreateConfigFile(t *testing.T) {
 	defer cleanup()
 
 	conf := &Config{
+		fs:        fs,
+		mutator:   chezmoi.NewVerboseMutator(os.Stdout, chezmoi.NewFSMutator(fs), false),
 		SourceDir: "/home/user/.local/share/chezmoi",
 		stdin:     bytes.NewBufferString("john.smith@company.com\n"),
 		stdout:    &bytes.Buffer{},
 		bds:       xdg.NewTestBaseDirectorySpecification("/home/user", nil),
 	}
 
-	require.NoError(t, conf.createConfigFile(fs, chezmoi.NewFSMutator(fs)))
+	require.NoError(t, conf.createConfigFile())
 
 	vfst.RunTests(t, fs, "",
 		vfst.TestPath("/home/user/.config/chezmoi/chezmoi.yaml",
@@ -65,6 +67,8 @@ func TestInit(t *testing.T) {
 	defer cleanup()
 
 	c := &Config{
+		fs:        fs,
+		mutator:   chezmoi.NewVerboseMutator(os.Stdout, chezmoi.NewFSMutator(fs), false),
 		SourceDir: "/home/user/.local/share/chezmoi",
 		SourceVCS: sourceVCSConfig{
 			Command: "git",
@@ -72,7 +76,7 @@ func TestInit(t *testing.T) {
 		bds: xdg.NewTestBaseDirectorySpecification("/home/user", func(string) string { return "" }),
 	}
 
-	require.NoError(t, c.runInitCmd(fs, nil))
+	require.NoError(t, c.runInitCmd(nil, nil))
 	vfst.RunTests(t, fs, "",
 		vfst.TestPath("/home/user/.local/share/chezmoi",
 			vfst.TestIsDir,
@@ -94,6 +98,8 @@ func TestInitRepo(t *testing.T) {
 	defer cleanup()
 
 	c := &Config{
+		fs:        fs,
+		mutator:   chezmoi.NewVerboseMutator(os.Stdout, chezmoi.NewFSMutator(fs), false),
 		SourceDir: "/home/user/.local/share/chezmoi",
 		SourceVCS: sourceVCSConfig{
 			Command: "git",
@@ -103,7 +109,7 @@ func TestInitRepo(t *testing.T) {
 
 	wd, err := os.Getwd()
 	require.NoError(t, err)
-	require.NoError(t, c.runInitCmd(fs, []string{filepath.Join(wd, "testdata/gitrepo")}))
+	require.NoError(t, c.runInitCmd(nil, []string{filepath.Join(wd, "testdata/gitrepo")}))
 	vfst.RunTests(t, fs, "",
 		vfst.TestPath("/home/user/.local/share/chezmoi",
 			vfst.TestIsDir,
diff --git a/cmd/secretgeneric_posix_test.go b/cmd/secretgeneric_posix_test.go
index f582ae92b..b49b26cc7 100644
--- a/cmd/secretgeneric_posix_test.go
+++ b/cmd/secretgeneric_posix_test.go
@@ -2,8 +2,11 @@
 
 package cmd
 
+import "github.com/twpayne/chezmoi/internal/chezmoi"
+
 func getSecretTestConfig() (*Config, []string) {
 	return &Config{
+			mutator: chezmoi.NullMutator{},
 			GenericSecret: genericSecretCmdConfig{
 				Command: "date",
 			},
@@ -13,6 +16,7 @@ func getSecretTestConfig() (*Config, []string) {
 
 func getSecretJSONTestConfig() (*Config, []string) {
 	return &Config{
+			mutator: chezmoi.NullMutator{},
 			GenericSecret: genericSecretCmdConfig{
 				Command: "date",
 			},
diff --git a/cmd/secretgeneric_windows_test.go b/cmd/secretgeneric_windows_test.go
index c589f1b96..0a7425518 100644
--- a/cmd/secretgeneric_windows_test.go
+++ b/cmd/secretgeneric_windows_test.go
@@ -2,10 +2,13 @@
 
 package cmd
 
+import "github.com/twpayne/chezmoi/internal/chezmoi"
+
 func getSecretTestConfig() (*Config, []string) {
 	// Windows doesn't (usually) have "date", but powershell is included with
 	// all versions of Windows v7 or newer.
 	return &Config{
+			mutator: chezmoi.NullMutator{},
 			GenericSecret: genericSecretCmdConfig{
 				Command: "powershell.exe",
 			},
@@ -15,6 +18,7 @@ func getSecretTestConfig() (*Config, []string) {
 
 func getSecretJSONTestConfig() (*Config, []string) {
 	return &Config{
+			mutator: chezmoi.NullMutator{},
 			GenericSecret: genericSecretCmdConfig{
 				Command: "powershell.exe",
 			},
diff --git a/internal/chezmoi/targetstate_test.go b/internal/chezmoi/targetstate_test.go
index c0461a623..655c586fd 100644
--- a/internal/chezmoi/targetstate_test.go
+++ b/internal/chezmoi/targetstate_test.go
@@ -108,7 +108,7 @@ func TestEndToEnd(t *testing.T) {
 				Stdout:            os.Stdout,
 				Umask:             022,
 			}
-			assert.NoError(t, ts.Apply(fs, NewLoggingMutator(os.Stderr, NewFSMutator(fs), false), tc.follow, applyOptions))
+			assert.NoError(t, ts.Apply(fs, NewVerboseMutator(os.Stderr, NewFSMutator(fs), false), tc.follow, applyOptions))
 			vfst.RunTests(t, fs, "", tc.tests)
 		})
 	}