Repository: twpayne/chezmoi
Pull request #63 (2018-12-10 23:10:35)

Internal refactor


Patch:
diff --git a/.gitignore b/.gitignore
index 849ddff3b..9b1c8b133 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,1 +1,1 @@
-dist/
+/dist
diff --git a/cmd/add.go b/cmd/add.go
index b7420f386..508d12435 100644
--- a/cmd/add.go
+++ b/cmd/add.go
@@ -33,21 +33,21 @@ func init() {
 }
 
 func (c *Config) runAddCommand(fs vfs.FS, command *cobra.Command, args []string) error {
-	targetState, err := c.getTargetState(fs)
+	ts, err := c.getTargetState(fs)
 	if err != nil {
 		return err
 	}
-	actuator := c.getDefaultActuator(fs)
+	mutator := c.getDefaultMutator(fs)
 	info, err := fs.Stat(c.SourceDir)
 	switch {
 	case err == nil && info.Mode().IsDir():
 		if info.Mode()&os.ModePerm != 0700 {
-			if err := actuator.Chmod(c.SourceDir, 0700); err != nil {
+			if err := mutator.Chmod(c.SourceDir, 0700); err != nil {
 				return err
 			}
 		}
 	case os.IsNotExist(err):
-		if err := actuator.Mkdir(c.SourceDir, 0700); err != nil {
+		if err := mutator.Mkdir(c.SourceDir, 0700); err != nil {
 			return err
 		}
 	case err == nil:
@@ -65,12 +65,12 @@ func (c *Config) runAddCommand(fs vfs.FS, command *cobra.Command, args []string)
 				if err != nil {
 					return err
 				}
-				return targetState.Add(fs, path, info, c.add.empty, c.add.template, actuator)
+				return ts.Add(fs, path, info, c.add.empty, c.add.template, mutator)
 			}); err != nil {
 				return err
 			}
 		} else {
-			if err := targetState.Add(fs, path, nil, c.add.empty, c.add.template, actuator); err != nil {
+			if err := ts.Add(fs, path, nil, c.add.empty, c.add.template, mutator); err != nil {
 				return err
 			}
 		}
diff --git a/cmd/apply.go b/cmd/apply.go
index 9fb32166e..f0e193b74 100644
--- a/cmd/apply.go
+++ b/cmd/apply.go
@@ -16,6 +16,6 @@ func init() {
 }
 
 func (c *Config) runApplyCommand(fs vfs.FS, command *cobra.Command, args []string) error {
-	actuator := c.getDefaultActuator(fs)
-	return c.applyArgs(fs, args, actuator)
+	mutator := c.getDefaultMutator(fs)
+	return c.applyArgs(fs, args, mutator)
 }
diff --git a/cmd/archive.go b/cmd/archive.go
index f0a0a0bbf..53f734607 100644
--- a/cmd/archive.go
+++ b/cmd/archive.go
@@ -20,12 +20,12 @@ func init() {
 }
 
 func (c *Config) runArchiveCommand(fs vfs.FS, command *cobra.Command, args []string) error {
-	targetState, err := c.getTargetState(fs)
+	ts, err := c.getTargetState(fs)
 	if err != nil {
 		return err
 	}
 	w := tar.NewWriter(os.Stdout)
-	if err := targetState.Archive(w, os.FileMode(c.Umask)); err != nil {
+	if err := ts.Archive(w, os.FileMode(c.Umask)); err != nil {
 		return err
 	}
 	return w.Close()
diff --git a/cmd/cat.go b/cmd/cat.go
index 83a014cec..a093c75ed 100644
--- a/cmd/cat.go
+++ b/cmd/cat.go
@@ -21,11 +21,11 @@ func init() {
 }
 
 func (c *Config) runCatCommand(fs vfs.FS, command *cobra.Command, args []string) error {
-	targetState, err := c.getTargetState(fs)
+	ts, err := c.getTargetState(fs)
 	if err != nil {
 		return err
 	}
-	entries, err := c.getEntries(targetState, args)
+	entries, err := c.getEntries(ts, args)
 	if err != nil {
 		return err
 	}
diff --git a/cmd/chattr.go b/cmd/chattr.go
index 65f82b96e..a12002a8d 100644
--- a/cmd/chattr.go
+++ b/cmd/chattr.go
@@ -42,11 +42,11 @@ func (c *Config) runChattrCommand(fs vfs.FS, cmd *cobra.Command, args []string)
 	if err != nil {
 		return err
 	}
-	targetState, err := c.getTargetState(fs)
+	ts, err := c.getTargetState(fs)
 	if err != nil {
 		return err
 	}
-	entries, err := c.getEntries(targetState, args[1:])
+	entries, err := c.getEntries(ts, args[1:])
 	if err != nil {
 		return err
 	}
@@ -80,11 +80,11 @@ func (c *Config) runChattrCommand(fs vfs.FS, cmd *cobra.Command, args []string)
 			newSourceName = psfn.SourceFileName()
 		}
 		if newSourceName != oldSourceName {
-			renames[filepath.Join(targetState.SourceDir, oldSourceName)] = filepath.Join(targetState.SourceDir, newSourceName)
+			renames[filepath.Join(ts.SourceDir, oldSourceName)] = filepath.Join(ts.SourceDir, newSourceName)
 		}
 	}
 
-	actuator := c.getDefaultActuator(fs)
+	mutator := c.getDefaultMutator(fs)
 
 	// Sort oldpaths in reverse so we rename files before their parent
 	// directories.
@@ -94,7 +94,7 @@ func (c *Config) runChattrCommand(fs vfs.FS, cmd *cobra.Command, args []string)
 	}
 	sort.Sort(sort.Reverse(sort.StringSlice(oldpaths)))
 	for _, oldpath := range oldpaths {
-		if err := actuator.Rename(oldpath, renames[oldpath]); err != nil {
+		if err := mutator.Rename(oldpath, renames[oldpath]); err != nil {
 			return err
 		}
 	}
diff --git a/cmd/cmd.go b/cmd/cmd.go
index a542344c8..79f903f5e 100644
--- a/cmd/cmd.go
+++ b/cmd/cmd.go
@@ -54,20 +54,20 @@ func (c *Config) addFunc(key string, value interface{}) {
 	c.funcs[key] = value
 }
 
-func (c *Config) applyArgs(fs vfs.FS, args []string, actuator chezmoi.Actuator) error {
-	targetState, err := c.getTargetState(fs)
+func (c *Config) applyArgs(fs vfs.FS, args []string, mutator chezmoi.Mutator) error {
+	ts, err := c.getTargetState(fs)
 	if err != nil {
 		return err
 	}
 	if len(args) == 0 {
-		return targetState.Apply(fs, actuator)
+		return ts.Apply(fs, mutator)
 	}
-	entries, err := c.getEntries(targetState, args)
+	entries, err := c.getEntries(ts, args)
 	if err != nil {
 		return err
 	}
 	for _, entry := range entries {
-		if err := entry.Apply(fs, targetState.TargetDir, targetState.Umask, actuator); err != nil {
+		if err := entry.Apply(fs, ts.TargetDir, ts.Umask, mutator); err != nil {
 			return err
 		}
 	}
@@ -88,17 +88,17 @@ func (c *Config) exec(argv []string) error {
 	return syscall.Exec(path, argv, os.Environ())
 }
 
-func (c *Config) getDefaultActuator(fs vfs.FS) chezmoi.Actuator {
-	var actuator chezmoi.Actuator
+func (c *Config) getDefaultMutator(fs vfs.FS) chezmoi.Mutator {
+	var mutator chezmoi.Mutator
 	if c.DryRun {
-		actuator = chezmoi.NullActuator
+		mutator = chezmoi.NullMutator
 	} else {
-		actuator = chezmoi.NewFSActuator(fs, c.TargetDir)
+		mutator = chezmoi.NewFSMutator(fs, c.TargetDir)
 	}
 	if c.Verbose {
-		actuator = chezmoi.NewLoggingActuator(os.Stdout, actuator)
+		mutator = chezmoi.NewLoggingMutator(os.Stdout, mutator)
 	}
-	return actuator
+	return mutator
 }
 
 func getDefaultData() (map[string]interface{}, error) {
@@ -134,14 +134,14 @@ func getDefaultData() (map[string]interface{}, error) {
 	return data, nil
 }
 
-func (c *Config) getEntries(targetState *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 := targetState.Get(targetPath)
+		entry, err := ts.Get(targetPath)
 		if err != nil {
 			return nil, err
 		}
@@ -164,12 +164,12 @@ func (c *Config) getTargetState(fs vfs.FS) (*chezmoi.TargetState, error) {
 	for key, value := range c.Data {
 		data[key] = value
 	}
-	targetState := chezmoi.NewTargetState(c.TargetDir, os.FileMode(c.Umask), c.SourceDir, data, c.funcs)
+	ts := chezmoi.NewTargetState(c.TargetDir, os.FileMode(c.Umask), c.SourceDir, data, c.funcs)
 	readOnlyFS := vfs.NewReadOnlyFS(fs)
-	if err := targetState.Populate(readOnlyFS); err != nil {
+	if err := ts.Populate(readOnlyFS); err != nil {
 		return nil, err
 	}
-	return targetState, nil
+	return ts, nil
 }
 
 func makeRunE(runCommand func(vfs.FS, *cobra.Command, []string) error) func(*cobra.Command, []string) error {
diff --git a/cmd/diff.go b/cmd/diff.go
index ae1c6cb2d..681451cb1 100644
--- a/cmd/diff.go
+++ b/cmd/diff.go
@@ -19,6 +19,6 @@ func init() {
 }
 
 func (c *Config) runDiffCommand(fs vfs.FS, command *cobra.Command, args []string) error {
-	actuator := chezmoi.NewLoggingActuator(os.Stdout, chezmoi.NullActuator)
-	return c.applyArgs(fs, args, actuator)
+	mutator := chezmoi.NewLoggingMutator(os.Stdout, chezmoi.NullMutator)
+	return c.applyArgs(fs, args, mutator)
 }
diff --git a/cmd/dump.go b/cmd/dump.go
index c36cb830c..10800b1e7 100644
--- a/cmd/dump.go
+++ b/cmd/dump.go
@@ -47,24 +47,24 @@ func (c *Config) runDumpCommand(fs vfs.FS, command *cobra.Command, args []string
 	if !ok {
 		return fmt.Errorf("unknown format: %s", c.dump.format)
 	}
-	targetState, err := c.getTargetState(fs)
+	ts, err := c.getTargetState(fs)
 	if err != nil {
 		return err
 	}
 	var concreteValue interface{}
 	if len(args) == 0 {
-		concreteValue, err = targetState.ConcreteValue(c.dump.recursive)
+		concreteValue, err = ts.ConcreteValue(c.dump.recursive)
 		if err != nil {
 			return err
 		}
 	} else {
-		entries, err := c.getEntries(targetState, args)
+		entries, err := c.getEntries(ts, args)
 		if err != nil {
 			return err
 		}
 		var concreteValues []interface{}
 		for _, entry := range entries {
-			entryConcreteValue, err := entry.ConcreteValue(targetState.TargetDir, targetState.SourceDir, c.dump.recursive)
+			entryConcreteValue, err := entry.ConcreteValue(ts.TargetDir, ts.SourceDir, c.dump.recursive)
 			if err != nil {
 				return err
 			}
diff --git a/cmd/edit.go b/cmd/edit.go
index 0b8f571f9..9ab232580 100644
--- a/cmd/edit.go
+++ b/cmd/edit.go
@@ -38,11 +38,11 @@ func (c *Config) runEditCommand(fs vfs.FS, command *cobra.Command, args []string
 	if c.edit.prompt {
 		c.edit.diff = true
 	}
-	targetState, err := c.getTargetState(fs)
+	ts, err := c.getTargetState(fs)
 	if err != nil {
 		return err
 	}
-	entries, err := c.getEntries(targetState, args)
+	entries, err := c.getEntries(ts, args)
 	if err != nil {
 		return err
 	}
@@ -71,17 +71,17 @@ func (c *Config) runEditCommand(fs vfs.FS, command *cobra.Command, args []string
 		return err
 	}
 	readOnlyFS := vfs.NewReadOnlyFS(fs)
-	applyActuator := c.getDefaultActuator(fs)
+	applyMutator := c.getDefaultMutator(fs)
 	for i, entry := range entries {
-		anyActuator := chezmoi.NewAnyActuator(chezmoi.NullActuator)
-		var actuator chezmoi.Actuator = anyActuator
+		anyMutator := chezmoi.NewAnyMutator(chezmoi.NullMutator)
+		var mutator chezmoi.Mutator = anyMutator
 		if c.edit.diff {
-			actuator = chezmoi.NewLoggingActuator(os.Stdout, actuator)
+			mutator = chezmoi.NewLoggingMutator(os.Stdout, mutator)
 		}
-		if err := entry.Apply(readOnlyFS, targetState.TargetDir, targetState.Umask, actuator); err != nil {
+		if err := entry.Apply(readOnlyFS, ts.TargetDir, ts.Umask, mutator); err != nil {
 			return err
 		}
-		if c.edit.apply && anyActuator.Actuated() {
+		if c.edit.apply && anyMutator.Mutated() {
 			if c.edit.prompt {
 				choice, err := prompt(fmt.Sprintf("Apply %s", args[i]), "ynqa")
 				if err != nil {
@@ -97,7 +97,7 @@ func (c *Config) runEditCommand(fs vfs.FS, command *cobra.Command, args []string
 					c.edit.prompt = false
 				}
 			}
-			if err := entry.Apply(readOnlyFS, targetState.TargetDir, targetState.Umask, applyActuator); err != nil {
+			if err := entry.Apply(readOnlyFS, ts.TargetDir, ts.Umask, applyMutator); err != nil {
 				return err
 			}
 		}
diff --git a/cmd/forget.go b/cmd/forget.go
index 3458e19fb..0e7977577 100644
--- a/cmd/forget.go
+++ b/cmd/forget.go
@@ -19,17 +19,17 @@ func init() {
 }
 
 func (c *Config) runForgetCommand(fs vfs.FS, command *cobra.Command, args []string) error {
-	targetState, err := c.getTargetState(fs)
+	ts, err := c.getTargetState(fs)
 	if err != nil {
 		return err
 	}
-	entries, err := c.getEntries(targetState, args)
+	entries, err := c.getEntries(ts, args)
 	if err != nil {
 		return err
 	}
-	actuator := c.getDefaultActuator(fs)
+	mutator := c.getDefaultMutator(fs)
 	for _, entry := range entries {
-		if err := actuator.RemoveAll(filepath.Join(c.SourceDir, entry.SourceName())); err != nil {
+		if err := mutator.RemoveAll(filepath.Join(c.SourceDir, entry.SourceName())); err != nil {
 			return err
 		}
 	}
diff --git a/cmd/import.go b/cmd/import.go
index 92d93d4d0..21def2ea3 100644
--- a/cmd/import.go
+++ b/cmd/import.go
@@ -42,7 +42,7 @@ func init() {
 }
 
 func (c *Config) runImportCommand(fs vfs.FS, cmd *cobra.Command, args []string) error {
-	targetState, err := c.getTargetState(fs)
+	ts, err := c.getTargetState(fs)
 	if err != nil {
 		return err
 	}
@@ -70,12 +70,12 @@ func (c *Config) runImportCommand(fs vfs.FS, cmd *cobra.Command, args []string)
 			return fmt.Errorf("%s: unknown format", arg)
 		}
 	}
-	actuator := c.getDefaultActuator(fs)
+	mutator := c.getDefaultMutator(fs)
 	if c.importC.removeDestination {
-		entry, err := targetState.Get(c.importC.destination)
+		entry, err := ts.Get(c.importC.destination)
 		switch {
 		case err == nil:
-			if err := actuator.RemoveAll(filepath.Join(c.SourceDir, entry.SourceName())); err != nil {
+			if err := mutator.RemoveAll(filepath.Join(c.SourceDir, entry.SourceName())); err != nil {
 				return err
 			}
 		case os.IsNotExist(err):
@@ -83,5 +83,5 @@ func (c *Config) runImportCommand(fs vfs.FS, cmd *cobra.Command, args []string)
 			return err
 		}
 	}
-	return targetState.AddArchive(tar.NewReader(r), c.importC.destination, c.importC.stripComponents, actuator)
+	return ts.Import(tar.NewReader(r), c.importC.destination, c.importC.stripComponents, mutator)
 }
diff --git a/cmd/remove.go b/cmd/remove.go
index 7e088b2b3..7c7fc9762 100644
--- a/cmd/remove.go
+++ b/cmd/remove.go
@@ -25,20 +25,20 @@ func init() {
 }
 
 func (c *Config) runRemoveCommand(fs vfs.FS, command *cobra.Command, args []string) error {
-	targetState, err := c.getTargetState(fs)
+	ts, err := c.getTargetState(fs)
 	if err != nil {
 		return err
 	}
-	entries, err := c.getEntries(targetState, args)
+	entries, err := c.getEntries(ts, args)
 	if err != nil {
 		return nil
 	}
-	actuator := c.getDefaultActuator(fs)
+	mutator := c.getDefaultMutator(fs)
 	for _, entry := range entries {
-		if err := actuator.RemoveAll(filepath.Join(c.TargetDir, entry.TargetName())); err != nil && !os.IsNotExist(err) {
+		if err := mutator.RemoveAll(filepath.Join(c.TargetDir, entry.TargetName())); err != nil && !os.IsNotExist(err) {
 			return err
 		}
-		if err := actuator.RemoveAll(filepath.Join(c.SourceDir, entry.SourceName())); err != nil && !os.IsNotExist(err) {
+		if err := mutator.RemoveAll(filepath.Join(c.SourceDir, entry.SourceName())); err != nil && !os.IsNotExist(err) {
 			return err
 		}
 	}
diff --git a/cmd/source_path.go b/cmd/source_path.go
index dd77bffc8..adcfdb576 100644
--- a/cmd/source_path.go
+++ b/cmd/source_path.go
@@ -19,20 +19,20 @@ func init() {
 }
 
 func (c *Config) runSourcePathCommand(fs vfs.FS, cmd *cobra.Command, args []string) error {
-	targetState, err := c.getTargetState(fs)
+	ts, err := c.getTargetState(fs)
 	if err != nil {
 		return err
 	}
 	if len(args) == 0 {
-		_, err := fmt.Println(targetState.SourceDir)
+		_, err := fmt.Println(ts.SourceDir)
 		return err
 	}
-	entries, err := c.getEntries(targetState, args)
+	entries, err := c.getEntries(ts, args)
 	if err != nil {
 		return err
 	}
 	for _, entry := range entries {
-		if _, err := fmt.Println(filepath.Join(targetState.SourceDir, entry.SourceName())); err != nil {
+		if _, err := fmt.Println(filepath.Join(ts.SourceDir, entry.SourceName())); err != nil {
 			return err
 		}
 	}
diff --git a/cmd/verify.go b/cmd/verify.go
index 828e8a8df..7917a8945 100644
--- a/cmd/verify.go
+++ b/cmd/verify.go
@@ -19,11 +19,11 @@ func init() {
 }
 
 func (c *Config) runVerifyCommand(fs vfs.FS, command *cobra.Command, args []string) error {
-	actuator := chezmoi.NewAnyActuator(chezmoi.NullActuator)
-	if err := c.applyArgs(fs, args, actuator); err != nil {
+	mutator := chezmoi.NewAnyMutator(chezmoi.NullMutator)
+	if err := c.applyArgs(fs, args, mutator); err != nil {
 		return err
 	}
-	if actuator.Actuated() {
+	if mutator.Mutated() {
 		os.Exit(1)
 	}
 	return nil
diff --git a/lib/chezmoi/any_actuator.go b/lib/chezmoi/any_actuator.go
deleted file mode 100644
index 42ba70a9c..000000000
--- a/lib/chezmoi/any_actuator.go
+++ /dev/null
@@ -1,59 +0,0 @@
-package chezmoi
-
-import "os"
-
-// An AnyActuator wraps another Actuator and records if any of its methods are
-// called.
-type AnyActuator struct {
-	a        Actuator
-	actuated bool
-}
-
-// NewAnyActuator returns a new AnyActuator.
-func NewAnyActuator(a Actuator) *AnyActuator {
-	return &AnyActuator{
-		a:        a,
-		actuated: false,
-	}
-}
-
-// Actuated returns true if any of its methods have been called.
-func (a *AnyActuator) Actuated() bool {
-	return a.actuated
-}
-
-// Chmod implements Actuator.Chmod.
-func (a *AnyActuator) Chmod(name string, mode os.FileMode) error {
-	a.actuated = true
-	return a.a.Chmod(name, mode)
-}
-
-// Mkdir implements Actuator.Mkdir.
-func (a *AnyActuator) Mkdir(name string, perm os.FileMode) error {
-	a.actuated = true
-	return a.a.Mkdir(name, perm)
-}
-
-// RemoveAll implements Actuator.RemoveAll.
-func (a *AnyActuator) RemoveAll(name string) error {
-	a.actuated = true
-	return a.a.RemoveAll(name)
-}
-
-// Rename implements Actuator.Rename.
-func (a *AnyActuator) Rename(oldpath, newpath string) error {
-	a.actuated = true
-	return a.a.Rename(oldpath, newpath)
-}
-
-// WriteFile implements Actuator.WriteFile.
-func (a *AnyActuator) WriteFile(name string, data []byte, perm os.FileMode, currData []byte) error {
-	a.actuated = true
-	return a.a.WriteFile(name, data, perm, currData)
-}
-
-// WriteSymlink implements Actuator.WriteSymlink.
-func (a *AnyActuator) WriteSymlink(oldname, newname string) error {
-	a.actuated = true
-	return a.a.WriteSymlink(oldname, newname)
-}
diff --git a/lib/chezmoi/any_mutator.go b/lib/chezmoi/any_mutator.go
new file mode 100644
index 000000000..182840a04
--- /dev/null
+++ b/lib/chezmoi/any_mutator.go
@@ -0,0 +1,59 @@
+package chezmoi
+
+import "os"
+
+// An AnyMutator wraps another Mutator and records if any of its methods are
+// called.
+type AnyMutator struct {
+	m       Mutator
+	mutated bool
+}
+
+// NewAnyMutator returns a new AnyMutator.
+func NewAnyMutator(m Mutator) *AnyMutator {
+	return &AnyMutator{
+		m:       m,
+		mutated: false,
+	}
+}
+
+// Chmod implements Mutator.Chmod.
+func (m *AnyMutator) Chmod(name string, mode os.FileMode) error {
+	m.mutated = true
+	return m.m.Chmod(name, mode)
+}
+
+// Mkdir implements Mutator.Mkdir.
+func (m *AnyMutator) Mkdir(name string, perm os.FileMode) error {
+	m.mutated = true
+	return m.m.Mkdir(name, perm)
+}
+
+// Mutated returns true if any of its methods have been called.
+func (m *AnyMutator) Mutated() bool {
+	return m.mutated
+}
+
+// RemoveAll implements Mutator.RemoveAll.
+func (m *AnyMutator) RemoveAll(name string) error {
+	m.mutated = true
+	return m.m.RemoveAll(name)
+}
+
+// Rename implements Mutator.Rename.
+func (m *AnyMutator) Rename(oldpath, newpath string) error {
+	m.mutated = true
+	return m.m.Rename(oldpath, newpath)
+}
+
+// WriteFile implements Mutator.WriteFile.
+func (m *AnyMutator) WriteFile(name string, data []byte, perm os.FileMode, currData []byte) error {
+	m.mutated = true
+	return m.m.WriteFile(name, data, perm, currData)
+}
+
+// WriteSymlink implements Mutator.WriteSymlink.
+func (m *AnyMutator) WriteSymlink(oldname, newname string) error {
+	m.mutated = true
+	return m.m.WriteSymlink(oldname, newname)
+}
diff --git a/lib/chezmoi/chezmoi.go b/lib/chezmoi/chezmoi.go
index 41550bdf2..37b74e7d2 100644
--- a/lib/chezmoi/chezmoi.go
+++ b/lib/chezmoi/chezmoi.go
@@ -2,18 +2,11 @@ package chezmoi
 
 import (
 	"archive/tar"
-	"bytes"
 	"fmt"
-	"io"
-	"io/ioutil"
 	"os"
-	"os/user"
 	"path/filepath"
 	"sort"
-	"strconv"
 	"strings"
-	"text/template"
-	"time"
 
 	vfs "github.com/twpayne/go-vfs"
 )
@@ -35,7 +28,7 @@ type templateFuncError struct {
 
 // An Entry is either a Dir, a File, or a Symlink.
 type Entry interface {
-	Apply(fs vfs.FS, targetDir string, umask os.FileMode, actuator Actuator) error
+	Apply(fs vfs.FS, targetDir string, umask os.FileMode, mutator Mutator) error
 	ConcreteValue(targetDir, sourceDir string, recursive bool) (interface{}, error)
 	Evaluate() error
 	SourceName() string
@@ -43,72 +36,6 @@ type Entry interface {
 	archive(w *tar.Writer, headerTemplate *tar.Header, umask os.FileMode) error
 }
 
-// A File represents the target state of a file.
-type File struct {
-	sourceName       string
-	targetName       string
-	Empty            bool
-	Perm             os.FileMode
-	Template         bool
-	contents         []byte
-	contentsErr      error
-	evaluateContents func() ([]byte, error)
-}
-
-type fileConcreteValue struct {
-	Type       string `json:"type" yaml:"type"`
-	SourcePath string `json:"sourcePath" yaml:"sourcePath"`
-	TargetPath string `json:"targetPath" yaml:"targetPath"`
-	Empty      bool   `json:"empty" yaml:"empty"`
-	Perm       int    `json:"perm" yaml:"perm"`
-	Template   bool   `json:"template" yaml:"template"`
-	Contents   string `json:"contents" yaml:"contents"`
-}
-
-// A Dir represents the target state of a directory.
-type Dir struct {
-	sourceName string
-	targetName string
-	Perm       os.FileMode
-	Entries    map[string]Entry
-}
-
-type dirConcreteValue struct {
-	Type       string        `json:"type" yaml:"type"`
-	SourcePath string        `json:"sourcePath" yaml:"sourcePath"`
-	TargetPath string        `json:"targetPath" yaml:"targetPath"`
-	Perm       int           `json:"perm" yaml:"perm"`
-	Entries    []interface{} `json:"entries" yaml:"entries"`
-}
-
-// A Symlink represents the target state of a symlink.
-type Symlink struct {
-	sourceName       string
-	targetName       string
-	Template         bool
-	linkName         string
-	linkNameErr      error
-	evaluateLinkName func() (string, error)
-}
-
-type symlinkConcreteValue struct {
-	Type       string `json:"type" yaml:"type"`
-	SourcePath string `json:"sourcePath" yaml:"sourcePath"`
-	TargetPath string `json:"targetPath" yaml:"targetPath"`
-	Template   bool   `json:"template" yaml:"template"`
-	LinkName   string `json:"linkName" yaml:"linkName"`
-}
-
-// A TargetState represents the root target state.
-type TargetState struct {
-	TargetDir string
-	Umask     os.FileMode
-	SourceDir string
-	Data      map[string]interface{}
-	Funcs     template.FuncMap
-	Entries   map[string]Entry
-}
-
 // ParsedSourceDirName is a parsed source dir name.
 type ParsedSourceDirName struct {
 	DirName string
@@ -128,841 +55,6 @@ type parsedSourceFilePath struct {
 	dirNames []string
 }
 
-// newDir returns a new directory state.
-func newDir(sourceName string, targetName string, perm os.FileMode) *Dir {
-	return &Dir{
-		sourceName: sourceName,
-		targetName: targetName,
-		Perm:       perm,
-		Entries:    make(map[string]Entry),
-	}
-}
-
-// archive writes d to w.
-func (d *Dir) archive(w *tar.Writer, headerTemplate *tar.Header, umask os.FileMode) error {
-	header := *headerTemplate
-	header.Typeflag = tar.TypeDir
-	header.Name = d.targetName
-	header.Mode = int64(d.Perm &^ umask & os.ModePerm)
-	if err := w.WriteHeader(&header); err != nil {
-		return err
-	}
-	for _, entryName := range sortedEntryNames(d.Entries) {
-		if err := d.Entries[entryName].archive(w, headerTemplate, umask); err != nil {
-			return err
-		}
-	}
-	return nil
-}
-
-// Apply ensures that targetDir in fs matches d.
-func (d *Dir) Apply(fs vfs.FS, targetDir string, umask os.FileMode, actuator Actuator) error {
-	targetPath := filepath.Join(targetDir, d.targetName)
-	info, err := fs.Lstat(targetPath)
-	switch {
-	case err == nil && info.Mode().IsDir():
-		if info.Mode()&os.ModePerm != d.Perm&^umask {
-			if err := actuator.Chmod(targetPath, d.Perm&^umask); err != nil {
-				return err
-			}
-		}
-	case err == nil:
-		if err := actuator.RemoveAll(targetPath); err != nil {
-			return err
-		}
-		fallthrough
-	case os.IsNotExist(err):
-		if err := actuator.Mkdir(targetPath, d.Perm&^umask); err != nil {
-			return err
-		}
-	default:
-		return err
-	}
-	for _, entryName := range sortedEntryNames(d.Entries) {
-		if err := d.Entries[entryName].Apply(fs, targetDir, umask, actuator); err != nil {
-			return err
-		}
-	}
-	return nil
-}
-
-// ConcreteValue implements Entry.ConcreteValue.
-func (d *Dir) ConcreteValue(targetDir, sourceDir string, recursive bool) (interface{}, error) {
-	var entryConcreteValues []interface{}
-	if recursive {
-		for _, entryName := range sortedEntryNames(d.Entries) {
-			entryConcreteValue, err := d.Entries[entryName].ConcreteValue(targetDir, sourceDir, recursive)
-			if err != nil {
-				return nil, err
-			}
-			entryConcreteValues = append(entryConcreteValues, entryConcreteValue)
-		}
-	}
-	return &dirConcreteValue{
-		Type:       "dir",
-		SourcePath: filepath.Join(sourceDir, d.SourceName()),
-		TargetPath: filepath.Join(targetDir, d.TargetName()),
-		Perm:       int(d.Perm),
-		Entries:    entryConcreteValues,
-	}, nil
-}
-
-// Evaluate evaluates all entries in d.
-func (d *Dir) Evaluate() error {
-	for _, entryName := range sortedEntryNames(d.Entries) {
-		if err := d.Entries[entryName].Evaluate(); err != nil {
-			return err
-		}
-	}
-	return nil
-}
-
-// Private returns true if d is private.
-func (d *Dir) Private() bool {
-	return d.Perm&os.ModePerm&077 == 0
-}
-
-// SourceName implements Entry.SourceName.
-func (d *Dir) SourceName() string {
-	return d.sourceName
-}
-
-// TargetName implements Entry.TargetName.
-func (d *Dir) TargetName() string {
-	return d.targetName
-}
-
-// archive writes f to w.
-func (f *File) archive(w *tar.Writer, headerTemplate *tar.Header, umask os.FileMode) error {
-	contents, err := f.Contents()
-	if err != nil {
-		return err
-	}
-	if len(contents) == 0 && !f.Empty {
-		return nil
-	}
-	header := *headerTemplate
-	header.Typeflag = tar.TypeReg
-	header.Name = f.targetName
-	header.Size = int64(len(contents))
-	header.Mode = int64(f.Perm &^ umask)
-	if err := w.WriteHeader(&header); err != nil {
-		return nil
-	}
-	_, err = w.Write(contents)
-	return err
-}
-
-// Apply ensures that the state of targetPath in fs matches f.
-func (f *File) Apply(fs vfs.FS, targetDir string, umask os.FileMode, actuator Actuator) error {
-	contents, err := f.Contents()
-	if err != nil {
-		return err
-	}
-	targetPath := filepath.Join(targetDir, f.targetName)
-	info, err := fs.Lstat(targetPath)
-	var currData []byte
-	switch {
-	case err == nil && info.Mode().IsRegular():
-		if len(contents) == 0 && !f.Empty {
-			return actuator.RemoveAll(targetPath)
-		}
-		currData, err = fs.ReadFile(targetPath)
-		if err != nil {
-			return err
-		}
-		if !bytes.Equal(currData, contents) {
-			break
-		}
-		if info.Mode()&os.ModePerm != f.Perm&^umask {
-			if err := actuator.Chmod(targetPath, f.Perm&^umask); err != nil {
-				return err
-			}
-		}
-		return nil
-	case err == nil:
-		if err := actuator.RemoveAll(targetPath); err != nil {
-			return err
-		}
-	case os.IsNotExist(err):
-	default:
-		return err
-	}
-	if len(contents) == 0 && !f.Empty {
-		return nil
-	}
-	return actuator.WriteFile(targetPath, contents, f.Perm&^umask, currData)
-}
-
-// ConcreteValue implements Entry.ConcreteValue.
-func (f *File) ConcreteValue(targetDir, sourceDir string, recursive bool) (interface{}, error) {
-	contents, err := f.Contents()
-	if err != nil {
-		return nil, err
-	}
-	return &fileConcreteValue{
-		Type:       "file",
-		SourcePath: filepath.Join(sourceDir, f.SourceName()),
-		TargetPath: filepath.Join(targetDir, f.TargetName()),
-		Empty:      f.Empty,
-		Perm:       int(f.Perm),
-		Template:   f.Template,
-		Contents:   string(contents),
-	}, nil
-}
-
-// Evaluate evaluates f's contents.
-func (f *File) Evaluate() error {
-	_, err := f.Contents()
-	return err
-}
-
-// Contents returns f's contents.
-func (f *File) Contents() ([]byte, error) {
-	if f.evaluateContents != nil {
-		f.contents, f.contentsErr = f.evaluateContents()
-		f.evaluateContents = nil
-	}
-	return f.contents, f.contentsErr
-}
-
-// Executable returns true is f is executable.
-func (f *File) Executable() bool {
-	return f.Perm&0111 != 0
-}
-
-// Private returns true if f is private.
-func (f *File) Private() bool {
-	return f.Perm&os.ModePerm&077 == 0
-}
-
-// SourceName implements Entry.SourceName.
-func (f *File) SourceName() string {
-	return f.sourceName
-}
-
-// TargetName implements Entry.TargetName.
-func (f *File) TargetName() string {
-	return f.targetName
-}
-
-// archive writes s to w.
-func (s *Symlink) archive(w *tar.Writer, headerTemplate *tar.Header, umask os.FileMode) error {
-	linkName, err := s.LinkName()
-	if err != nil {
-		return err
-	}
-	header := *headerTemplate
-	header.Name = s.targetName
-	header.Typeflag = tar.TypeSymlink
-	header.Linkname = linkName
-	return w.WriteHeader(&header)
-}
-
-// Apply ensures that the state of s's target in fs matches s.
-func (s *Symlink) Apply(fs vfs.FS, targetDir string, umask os.FileMode, actuator Actuator) error {
-	target, err := s.LinkName()
-	if err != nil {
-		return err
-	}
-	targetPath := filepath.Join(targetDir, s.targetName)
-	info, err := fs.Lstat(targetPath)
-	switch {
-	case err == nil && info.Mode()&os.ModeType == os.ModeSymlink:
-		currentTarget, err := fs.Readlink(targetPath)
-		if err != nil {
-			return err
-		}
-		if currentTarget == target {
-			return nil
-		}
-	case err == nil:
-	case os.IsNotExist(err):
-	default:
-		return err
-	}
-	return actuator.WriteSymlink(target, targetPath)
-}
-
-// ConcreteValue implements Entry.ConcreteValue.
-func (s *Symlink) ConcreteValue(targetDir, sourceDir string, recursive bool) (interface{}, error) {
-	linkName, err := s.LinkName()
-	if err != nil {
-		return nil, err
-	}
-	return &symlinkConcreteValue{
-		Type:       "symlink",
-		SourcePath: filepath.Join(sourceDir, s.SourceName()),
-		TargetPath: filepath.Join(targetDir, s.TargetName()),
-		Template:   s.Template,
-		LinkName:   linkName,
-	}, nil
-}
-
-// Evaluate evaluates s's target.
-func (s *Symlink) Evaluate() error {
-	_, err := s.LinkName()
-	return err
-}
-
-// SourceName implements Entry.SourceName.
-func (s *Symlink) SourceName() string {
-	return s.sourceName
-}
-
-// LinkName returns s's link name.
-func (s *Symlink) LinkName() (string, error) {
-	if s.evaluateLinkName != nil {
-		s.linkName, s.linkNameErr = s.evaluateLinkName()
-		s.evaluateLinkName = nil
-	}
-	return s.linkName, s.linkNameErr
-}
-
-// TargetName implements Entry.TargetName.
-func (s *Symlink) TargetName() string {
-	return s.targetName
-}
-
-// NewTargetState creates a new TargetState.
-func NewTargetState(targetDir string, umask os.FileMode, sourceDir string, data map[string]interface{}, funcs template.FuncMap) *TargetState {
-	return &TargetState{
-		TargetDir: targetDir,
-		Umask:     umask,
-		SourceDir: sourceDir,
-		Data:      data,
-		Funcs:     funcs,
-		Entries:   make(map[string]Entry),
-	}
-}
-
-// Add adds a new target to ts.
-func (ts *TargetState) Add(fs vfs.FS, targetPath string, info os.FileInfo, addEmpty, addTemplate bool, actuator Actuator) error {
-	if !filepath.HasPrefix(targetPath, ts.TargetDir) {
-		return fmt.Errorf("%s: outside target directory", targetPath)
-	}
-	targetName, err := filepath.Rel(ts.TargetDir, targetPath)
-	if err != nil {
-		return err
-	}
-	if info == nil {
-		var err error
-		info, err = fs.Lstat(targetPath)
-		if err != nil {
-			return err
-		}
-	}
-
-	// Add the parent directories, if needed.
-	dirSourceName := ""
-	entries := ts.Entries
-	if parentDirName := filepath.Dir(targetName); parentDirName != "." {
-		parentEntry, err := ts.findEntry(parentDirName)
-		if err != nil && !os.IsNotExist(err) {
-			return err
-		}
-		if parentEntry == nil {
-			if err := ts.Add(fs, filepath.Join(ts.TargetDir, parentDirName), nil, false, false, actuator); err != nil {
-				return err
-			}
-			parentEntry, err = ts.findEntry(parentDirName)
-			if err != nil {
-				return err
-			}
-		} else if _, ok := parentEntry.(*Dir); !ok {
-			return fmt.Errorf("%s: not a directory", parentDirName)
-		}
-		dir := parentEntry.(*Dir)
-		dirSourceName = dir.sourceName
-		entries = dir.Entries
-	}
-
-	name := filepath.Base(targetName)
-	switch {
-	case info.Mode().IsRegular():
-		if entry, ok := entries[name]; ok {
-			if _, ok := entry.(*File); !ok {
-				return fmt.Errorf("%s: already added and not a regular file", targetName)
-			}
-			return nil // entry already exists
-		}
-		if info.Size() == 0 && !addEmpty {
-			return nil
-		}
-		sourceName := ParsedSourceFileName{
-			FileName: name,
-			Mode:     info.Mode() & os.ModePerm,
-			Empty:    info.Size() == 0,
-			Template: addTemplate,
-		}.SourceFileName()
-		if dirSourceName != "" {
-			sourceName = filepath.Join(dirSourceName, sourceName)
-		}
-		data, err := fs.ReadFile(targetPath)
-		if err != nil {
-			return err
-		}
-		if addTemplate {
-			data = autoTemplate(data, ts.Data)
-		}
-		if err := actuator.WriteFile(filepath.Join(ts.SourceDir, sourceName), data, 0666&^ts.Umask, nil); err != nil {
-			return err
-		}
-		entries[name] = &File{
-			sourceName: sourceName,
-			targetName: targetName,
-			Empty:      len(data) == 0,
-			Perm:       info.Mode() & os.ModePerm,
-			Template:   addTemplate,
-			contents:   data,
-		}
-	case info.Mode().IsDir():
-		if entry, ok := entries[name]; ok {
-			if _, ok := entry.(*Dir); !ok {
-				return fmt.Errorf("%s: already added and not a directory", targetName)
-			}
-			return nil // entry already exists
-		}
-		sourceName := ParsedSourceDirName{
-			DirName: name,
-			Perm:    info.Mode() & os.ModePerm,
-		}.SourceDirName()
-		if dirSourceName != "" {
-			sourceName = filepath.Join(dirSourceName, sourceName)
-		}
-		if err := actuator.Mkdir(filepath.Join(ts.SourceDir, sourceName), 0777&^ts.Umask); err != nil {
-			return err
-		}
-		// If the directory is empty, add a .keep file so the directory is
-		// managed by git. Chezmoi will ignore the .keep file as it begins with
-		// a dot.
-		infos, err := fs.ReadDir(targetPath)
-		if err != nil {
-			return err
-		}
-		if len(infos) == 0 {
-			if err := actuator.WriteFile(filepath.Join(ts.SourceDir, sourceName, ".keep"), nil, 0666&^ts.Umask, nil); err != nil {
-				return err
-			}
-		}
-		entries[name] = newDir(sourceName, targetName, info.Mode()&os.ModePerm)
-	case info.Mode()&os.ModeType == os.ModeSymlink:
-		if entry, ok := entries[name]; ok {
-			if _, ok := entry.(*Symlink); !ok {
-				return fmt.Errorf("%s: already added and not a symlink", targetName)
-			}
-			return nil // entry already exists
-		}
-		sourceName := ParsedSourceFileName{
-			FileName: name,
-			Mode:     os.ModeSymlink,
-		}.SourceFileName()
-		if dirSourceName != "" {
-			sourceName = filepath.Join(dirSourceName, sourceName)
-		}
-		data, err := fs.Readlink(targetPath)
-		if err != nil {
-			return err
-		}
-		if err := actuator.WriteFile(filepath.Join(ts.SourceDir, sourceName), []byte(data), 0666&^ts.Umask, nil); err != nil {
-			return err
-		}
-		entries[name] = &Symlink{
-			sourceName: sourceName,
-			targetName: targetName,
-			linkName:   data,
-		}
-	default:
-		return fmt.Errorf("%s: not a regular file or directory", targetName)
-	}
-	return nil
-}
-
-// Archive writes ts to w.
-func (ts *TargetState) Archive(w *tar.Writer, umask os.FileMode) error {
-	currentUser, err := user.Current()
-	if err != nil {
-		return err
-	}
-	uid, err := strconv.Atoi(currentUser.Uid)
-	if err != nil {
-		return err
-	}
-	gid, err := strconv.Atoi(currentUser.Gid)
-	if err != nil {
-		return err
-	}
-	group, err := user.LookupGroupId(currentUser.Gid)
-	if err != nil {
-		return err
-	}
-	now := time.Now()
-	headerTemplate := tar.Header{
-		Uid:        uid,
-		Gid:        gid,
-		Uname:      currentUser.Username,
-		Gname:      group.Name,
-		ModTime:    now,
-		AccessTime: now,
-		ChangeTime: now,
-	}
-	for _, entryName := range sortedEntryNames(ts.Entries) {
-		if err := ts.Entries[entryName].archive(w, &headerTemplate, umask); err != nil {
-			return err
-		}
-	}
-	return nil
-}
-
-// Apply ensures that ts.TargetDir in fs matches ts.
-func (ts *TargetState) Apply(fs vfs.FS, actuator Actuator) error {
-	for _, entryName := range sortedEntryNames(ts.Entries) {
-		if err := ts.Entries[entryName].Apply(fs, ts.TargetDir, ts.Umask, actuator); err != nil {
-			return err
-		}
-	}
-	return nil
-}
-
-// ConcreteValue returns a value suitable for serialization.
-func (ts *TargetState) ConcreteValue(recursive bool) (interface{}, error) {
-	var entryConcreteValues []interface{}
-	for _, entryName := range sortedEntryNames(ts.Entries) {
-		entryConcreteValue, err := ts.Entries[entryName].ConcreteValue(ts.TargetDir, ts.SourceDir, recursive)
-		if err != nil {
-			return nil, err
-		}
-		entryConcreteValues = append(entryConcreteValues, entryConcreteValue)
-	}
-	return entryConcreteValues, nil
-}
-
-// Evaluate evaluates all of the entries in ts.
-func (ts *TargetState) Evaluate() error {
-	for _, entryName := range sortedEntryNames(ts.Entries) {
-		if err := ts.Entries[entryName].Evaluate(); err != nil {
-			return err
-		}
-	}
-	return nil
-}
-
-// Get returns the state of the given target, or nil if no such target is found.
-func (ts *TargetState) Get(target string) (Entry, error) {
-	if !filepath.HasPrefix(target, ts.TargetDir) {
-		return nil, fmt.Errorf("%s: outside target directory", target)
-	}
-	targetName, err := filepath.Rel(ts.TargetDir, target)
-	if err != nil {
-		return nil, err
-	}
-	return ts.findEntry(targetName)
-}
-
-func (ts *TargetState) AddArchive(r *tar.Reader, destinationDir string, stripComponents int, actuator Actuator) error {
-	for {
-		header, err := r.Next()
-		if err == io.EOF {
-			break
-		} else if err != nil {
-			return err
-		}
-		switch header.Typeflag {
-		case tar.TypeDir, tar.TypeReg, tar.TypeSymlink:
-			if err := ts.addArchiveHeader(r, header, destinationDir, stripComponents, actuator); err != nil {
-				return err
-			}
-		case tar.TypeXGlobalHeader:
-		default:
-			return fmt.Errorf("%s: unspported typeflag '%c'", header.Name, header.Typeflag)
-		}
-	}
-	return nil
-}
-
-// Populate walks fs from ts.SourceDir to populate ts.
-func (ts *TargetState) Populate(fs vfs.FS) error {
-	return vfs.Walk(fs, ts.SourceDir, func(path string, info os.FileInfo, _ error) error {
-		relPath, err := filepath.Rel(ts.SourceDir, path)
-		if err != nil {
-			return err
-		}
-		if relPath == "." {
-			return nil
-		}
-		// Ignore all files and directories beginning with "."
-		if _, name := filepath.Split(relPath); strings.HasPrefix(name, ".") {
-			if info.IsDir() {
-				return filepath.SkipDir
-			}
-			return nil
-		}
-		switch {
-		case info.Mode().IsRegular():
-			psfp := parseSourceFilePath(relPath)
-			entries, err := ts.findEntries(psfp.dirNames)
-			if err != nil {
-				return err
-			}
-
-			targetName := filepath.Join(append(psfp.dirNames, psfp.FileName)...)
-			var entry Entry
-			switch psfp.Mode & os.ModeType {
-			case 0:
-				evaluateContents := func() ([]byte, error) {
-					return fs.ReadFile(path)
-				}
-				if psfp.Template {
-					evaluateContents = func() ([]byte, error) {
-						return ts.executeTemplate(fs, path)
-					}
-				}
-				entry = &File{
-					sourceName:       relPath,
-					targetName:       targetName,
-					Empty:            psfp.Empty,
-					Perm:             psfp.Mode & os.ModePerm,
-					Template:         psfp.Template,
-					evaluateContents: evaluateContents,
-				}
-			case os.ModeSymlink:
-				evaluateLinkName := func() (string, error) {
-					data, err := fs.ReadFile(path)
-					return string(data), err
-				}
-				if psfp.Template {
-					evaluateLinkName = func() (string, error) {
-						data, err := ts.executeTemplate(fs, path)
-						return string(data), err
-					}
-				}
-				entry = &Symlink{
-					sourceName:       relPath,
-					targetName:       targetName,
-					Template:         psfp.Template,
-					evaluateLinkName: evaluateLinkName,
-				}
-			default:
-				return fmt.Errorf("%v: unsupported mode: %d", path, psfp.Mode&os.ModeType)
-			}
-			entries[psfp.FileName] = entry
-		case info.Mode().IsDir():
-			components := splitPathList(relPath)
-			dirNames, perms := parseDirNameComponents(components)
-			targetName := filepath.Join(dirNames...)
-			entries, err := ts.findEntries(dirNames[:len(dirNames)-1])
-			if err != nil {
-				return err
-			}
-			dirName := dirNames[len(dirNames)-1]
-			perm := perms[len(perms)-1]
-			entries[dirName] = newDir(relPath, targetName, perm)
-		default:
-			return fmt.Errorf("unsupported file type: %s", path)
-		}
-		return nil
-	})
-}
-
-func (ts *TargetState) addArchiveHeader(r *tar.Reader, header *tar.Header, destinationDir string, stripComponents int, actuator Actuator) error {
-	targetPath := header.Name
-	if stripComponents > 0 {
-		targetPath = filepath.Join(strings.Split(targetPath, string(os.PathSeparator))[stripComponents:]...)
-	}
-	if destinationDir != "" {
-		targetPath = filepath.Join(destinationDir, targetPath)
-	} else {
-		targetPath = filepath.Join(ts.TargetDir, targetPath)
-	}
-	targetName, err := filepath.Rel(ts.TargetDir, targetPath)
-	if err != nil {
-		return err
-	}
-	parentDirSourceName := ""
-	entries := ts.Entries
-	if parentDirName := filepath.Dir(targetName); parentDirName != "." {
-		parentEntry, err := ts.findEntry(parentDirName)
-		if err != nil {
-			return err
-		}
-		parentDir, ok := parentEntry.(*Dir)
-		if !ok {
-			return fmt.Errorf("%s: parent is not a directory", targetName)
-		}
-		parentDirSourceName = parentDir.sourceName
-		entries = parentDir.Entries
-	}
-	name := filepath.Base(targetName)
-	switch header.Typeflag {
-	case tar.TypeReg:
-		var existingFile *File
-		var existingContents []byte
-		if entry, ok := entries[name]; ok {
-			existingFile, ok = entry.(*File)
-			if !ok {
-				return fmt.Errorf("%s: already added and not a regular file", targetName)
-			}
-			existingContents, err = existingFile.Contents()
-			if err != nil {
-				return err
-			}
-		}
-		perm := os.FileMode(header.Mode) & os.ModePerm
-		empty := header.Size == 0
-		sourceName := ParsedSourceFileName{
-			FileName: name,
-			Mode:     perm,
-			Empty:    empty,
-		}.SourceFileName()
-		if parentDirSourceName != "" {
-			sourceName = filepath.Join(parentDirSourceName, sourceName)
-		}
-		contents, err := ioutil.ReadAll(r)
-		if err != nil {
-			return err
-		}
-		file := &File{
-			sourceName: sourceName,
-			targetName: targetName,
-			Empty:      empty,
-			Perm:       perm,
-			Template:   false,
-			contents:   contents,
-		}
-		if existingFile != nil {
-			if bytes.Equal(existingFile.contents, file.contents) {
-				if existingFile.sourceName == file.sourceName {
-					return nil
-				}
-				return actuator.Rename(filepath.Join(ts.SourceDir, existingFile.sourceName), filepath.Join(ts.SourceDir, file.sourceName))
-			}
-			if err := actuator.RemoveAll(filepath.Join(ts.SourceDir, existingFile.sourceName)); err != nil {
-				return err
-			}
-		}
-		entries[name] = file
-		return actuator.WriteFile(filepath.Join(ts.SourceDir, sourceName), contents, 0666&^ts.Umask, existingContents)
-	case tar.TypeDir:
-		var existingDir *Dir
-		if entry, ok := entries[name]; ok {
-			existingDir, ok = entry.(*Dir)
-			if !ok {
-				return fmt.Errorf("%s: already added and not a directory", targetName)
-			}
-		}
-		perm := os.FileMode(header.Mode) & os.ModePerm
-		sourceName := ParsedSourceDirName{
-			DirName: name,
-			Perm:    perm,
-		}.SourceDirName()
-		if parentDirSourceName != "" {
-			sourceName = filepath.Join(parentDirSourceName, sourceName)
-		}
-		dir := newDir(sourceName, targetName, perm)
-		if existingDir != nil {
-			if existingDir.sourceName == dir.sourceName {
-				return nil
-			}
-			return actuator.Rename(filepath.Join(ts.SourceDir, existingDir.sourceName), filepath.Join(ts.SourceDir, dir.sourceName))
-		}
-		// FIXME Add a .keep file if the directory is empty
-		entries[name] = dir
-		return actuator.Mkdir(filepath.Join(ts.SourceDir, sourceName), 0777&^ts.Umask)
-	case tar.TypeSymlink:
-		var existingSymlink *Symlink
-		var existingLinkName string
-		if entry, ok := entries[name]; ok {
-			existingSymlink, ok = entry.(*Symlink)
-			if !ok {
-				return fmt.Errorf("%s: already added and not a symlink", targetName)
-			}
-			existingLinkName, err = existingSymlink.LinkName()
-			if err != nil {
-				return err
-			}
-		}
-		sourceName := ParsedSourceFileName{
-			FileName: name,
-			Mode:     os.ModeSymlink,
-		}.SourceFileName()
-		if parentDirSourceName != "" {
-			sourceName = filepath.Join(parentDirSourceName, sourceName)
-		}
-		symlink := &Symlink{
-			sourceName: sourceName,
-			targetName: targetName,
-			linkName:   header.Linkname,
-		}
-		if existingSymlink != nil {
-			if existingSymlink.linkName == symlink.linkName {
-				if existingSymlink.sourceName == symlink.sourceName {
-					return nil
-				}
-				return actuator.Rename(filepath.Join(ts.SourceDir, existingSymlink.sourceName), filepath.Join(ts.SourceDir, symlink.sourceName))
-			}
-			if err := actuator.RemoveAll(filepath.Join(ts.SourceDir, existingSymlink.sourceName)); err != nil {
-				return err
-			}
-		}
-		entries[name] = symlink
-		return actuator.WriteFile(filepath.Join(ts.SourceDir, symlink.sourceName), []byte(symlink.linkName), 0666&^ts.Umask, []byte(existingLinkName))
-	default:
-		return fmt.Errorf("%s: unspported typeflag '%c'", header.Name, header.Typeflag)
-	}
-}
-
-func (ts *TargetState) executeTemplate(fs vfs.FS, path string) ([]byte, error) {
-	data, err := fs.ReadFile(path)
-	if err != nil {
-		return nil, err
-	}
-	return ts.executeTemplateData(path, data)
-}
-
-func (ts *TargetState) executeTemplateData(name string, data []byte) (_ []byte, err error) {
-	tmpl, err := template.New(name).Option("missingkey=error").Funcs(ts.Funcs).Parse(string(data))
-	if err != nil {
-		return nil, fmt.Errorf("%s: %v", name, err)
-	}
-	defer func() {
-		if r := recover(); r != nil {
-			if tfe, ok := r.(templateFuncError); ok {
-				err = tfe.err
-			} else {
-				panic(r)
-			}
-		}
-	}()
-	output := &bytes.Buffer{}
-	if err = tmpl.Execute(output, ts.Data); err != nil {
-		return nil, fmt.Errorf("%s: %v", name, err)
-	}
-	return output.Bytes(), nil
-}
-
-func (ts *TargetState) findEntries(dirNames []string) (map[string]Entry, error) {
-	entries := ts.Entries
-	for i, dirName := range dirNames {
-		if entry, ok := entries[dirName]; !ok {
-			return nil, os.ErrNotExist
-		} else if dir, ok := entry.(*Dir); ok {
-			entries = dir.Entries
-		} else {
-			return nil, fmt.Errorf("%s: not a directory", filepath.Join(dirNames[:i+1]...))
-		}
-	}
-	return entries, nil
-}
-
-func (ts *TargetState) findEntry(name string) (Entry, error) {
-	names := splitPathList(name)
-	entries, err := ts.findEntries(names[:len(names)-1])
-	if err != nil {
-		return nil, err
-	}
-	return entries[names[len(names)-1]], nil
-}
-
 // ReturnTemplateFuncError causes template execution to return an error.
 func ReturnTemplateFuncError(err error) {
 	panic(templateFuncError{
diff --git a/lib/chezmoi/dir.go b/lib/chezmoi/dir.go
new file mode 100644
index 000000000..b976b2762
--- /dev/null
+++ b/lib/chezmoi/dir.go
@@ -0,0 +1,129 @@
+package chezmoi
+
+import (
+	"archive/tar"
+	"os"
+	"path/filepath"
+
+	vfs "github.com/twpayne/go-vfs"
+)
+
+// A Dir represents the target state of a directory.
+type Dir struct {
+	sourceName string
+	targetName string
+	Perm       os.FileMode
+	Entries    map[string]Entry
+}
+
+type dirConcreteValue struct {
+	Type       string        `json:"type" yaml:"type"`
+	SourcePath string        `json:"sourcePath" yaml:"sourcePath"`
+	TargetPath string        `json:"targetPath" yaml:"targetPath"`
+	Perm       int           `json:"perm" yaml:"perm"`
+	Entries    []interface{} `json:"entries" yaml:"entries"`
+}
+
+// newDir returns a new directory state.
+func newDir(sourceName string, targetName string, perm os.FileMode) *Dir {
+	return &Dir{
+		sourceName: sourceName,
+		targetName: targetName,
+		Perm:       perm,
+		Entries:    make(map[string]Entry),
+	}
+}
+
+// Apply ensures that targetDir in fs matches d.
+func (d *Dir) Apply(fs vfs.FS, targetDir string, umask os.FileMode, mutator Mutator) error {
+	targetPath := filepath.Join(targetDir, d.targetName)
+	info, err := fs.Lstat(targetPath)
+	switch {
+	case err == nil && info.Mode().IsDir():
+		if info.Mode()&os.ModePerm != d.Perm&^umask {
+			if err := mutator.Chmod(targetPath, d.Perm&^umask); err != nil {
+				return err
+			}
+		}
+	case err == nil:
+		if err := mutator.RemoveAll(targetPath); err != nil {
+			return err
+		}
+		fallthrough
+	case os.IsNotExist(err):
+		if err := mutator.Mkdir(targetPath, d.Perm&^umask); err != nil {
+			return err
+		}
+	default:
+		return err
+	}
+	for _, entryName := range sortedEntryNames(d.Entries) {
+		if err := d.Entries[entryName].Apply(fs, targetDir, umask, mutator); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// ConcreteValue implements Entry.ConcreteValue.
+func (d *Dir) ConcreteValue(targetDir, sourceDir string, recursive bool) (interface{}, error) {
+	var entryConcreteValues []interface{}
+	if recursive {
+		for _, entryName := range sortedEntryNames(d.Entries) {
+			entryConcreteValue, err := d.Entries[entryName].ConcreteValue(targetDir, sourceDir, recursive)
+			if err != nil {
+				return nil, err
+			}
+			entryConcreteValues = append(entryConcreteValues, entryConcreteValue)
+		}
+	}
+	return &dirConcreteValue{
+		Type:       "dir",
+		SourcePath: filepath.Join(sourceDir, d.SourceName()),
+		TargetPath: filepath.Join(targetDir, d.TargetName()),
+		Perm:       int(d.Perm),
+		Entries:    entryConcreteValues,
+	}, nil
+}
+
+// Evaluate evaluates all entries in d.
+func (d *Dir) Evaluate() error {
+	for _, entryName := range sortedEntryNames(d.Entries) {
+		if err := d.Entries[entryName].Evaluate(); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// Private returns true if d is private.
+func (d *Dir) Private() bool {
+	return d.Perm&os.ModePerm&077 == 0
+}
+
+// SourceName implements Entry.SourceName.
+func (d *Dir) SourceName() string {
+	return d.sourceName
+}
+
+// TargetName implements Entry.TargetName.
+func (d *Dir) TargetName() string {
+	return d.targetName
+}
+
+// archive writes d to w.
+func (d *Dir) archive(w *tar.Writer, headerTemplate *tar.Header, umask os.FileMode) error {
+	header := *headerTemplate
+	header.Typeflag = tar.TypeDir
+	header.Name = d.targetName
+	header.Mode = int64(d.Perm & os.ModePerm &^ umask)
+	if err := w.WriteHeader(&header); err != nil {
+		return err
+	}
+	for _, entryName := range sortedEntryNames(d.Entries) {
+		if err := d.Entries[entryName].archive(w, headerTemplate, umask); err != nil {
+			return err
+		}
+	}
+	return nil
+}
diff --git a/lib/chezmoi/file.go b/lib/chezmoi/file.go
new file mode 100644
index 000000000..28473d646
--- /dev/null
+++ b/lib/chezmoi/file.go
@@ -0,0 +1,146 @@
+package chezmoi
+
+import (
+	"archive/tar"
+	"bytes"
+	"os"
+	"path/filepath"
+
+	vfs "github.com/twpayne/go-vfs"
+)
+
+// A File represents the target state of a file.
+type File struct {
+	sourceName       string
+	targetName       string
+	Empty            bool
+	Perm             os.FileMode
+	Template         bool
+	contents         []byte
+	contentsErr      error
+	evaluateContents func() ([]byte, error)
+}
+
+type fileConcreteValue struct {
+	Type       string `json:"type" yaml:"type"`
+	SourcePath string `json:"sourcePath" yaml:"sourcePath"`
+	TargetPath string `json:"targetPath" yaml:"targetPath"`
+	Empty      bool   `json:"empty" yaml:"empty"`
+	Perm       int    `json:"perm" yaml:"perm"`
+	Template   bool   `json:"template" yaml:"template"`
+	Contents   string `json:"contents" yaml:"contents"`
+}
+
+// Apply ensures that the state of targetPath in fs matches f.
+func (f *File) Apply(fs vfs.FS, targetDir string, umask os.FileMode, mutator Mutator) error {
+	contents, err := f.Contents()
+	if err != nil {
+		return err
+	}
+	targetPath := filepath.Join(targetDir, f.targetName)
+	info, err := fs.Lstat(targetPath)
+	var currData []byte
+	switch {
+	case err == nil && info.Mode().IsRegular():
+		if len(contents) == 0 && !f.Empty {
+			return mutator.RemoveAll(targetPath)
+		}
+		currData, err = fs.ReadFile(targetPath)
+		if err != nil {
+			return err
+		}
+		if !bytes.Equal(currData, contents) {
+			break
+		}
+		if info.Mode()&os.ModePerm != f.Perm&^umask {
+			if err := mutator.Chmod(targetPath, f.Perm&^umask); err != nil {
+				return err
+			}
+		}
+		return nil
+	case err == nil:
+		if err := mutator.RemoveAll(targetPath); err != nil {
+			return err
+		}
+	case os.IsNotExist(err):
+	default:
+		return err
+	}
+	if len(contents) == 0 && !f.Empty {
+		return nil
+	}
+	return mutator.WriteFile(targetPath, contents, f.Perm&^umask, currData)
+}
+
+// ConcreteValue implements Entry.ConcreteValue.
+func (f *File) ConcreteValue(targetDir, sourceDir string, recursive bool) (interface{}, error) {
+	contents, err := f.Contents()
+	if err != nil {
+		return nil, err
+	}
+	return &fileConcreteValue{
+		Type:       "file",
+		SourcePath: filepath.Join(sourceDir, f.SourceName()),
+		TargetPath: filepath.Join(targetDir, f.TargetName()),
+		Empty:      f.Empty,
+		Perm:       int(f.Perm),
+		Template:   f.Template,
+		Contents:   string(contents),
+	}, nil
+}
+
+// Evaluate evaluates f's contents.
+func (f *File) Evaluate() error {
+	_, err := f.Contents()
+	return err
+}
+
+// Contents returns f's contents.
+func (f *File) Contents() ([]byte, error) {
+	if f.evaluateContents != nil {
+		f.contents, f.contentsErr = f.evaluateContents()
+		f.evaluateContents = nil
+	}
+	return f.contents, f.contentsErr
+}
+
+// Executable returns true is f is executable.
+func (f *File) Executable() bool {
+	return f.Perm&0111 != 0
+}
+
+// Private returns true if f is private.
+func (f *File) Private() bool {
+	return f.Perm&os.ModePerm&077 == 0
+}
+
+// SourceName implements Entry.SourceName.
+func (f *File) SourceName() string {
+	return f.sourceName
+}
+
+// TargetName implements Entry.TargetName.
+func (f *File) TargetName() string {
+	return f.targetName
+}
+
+// archive writes f to w.
+func (f *File) archive(w *tar.Writer, headerTemplate *tar.Header, umask os.FileMode) error {
+	contents, err := f.Contents()
+	if err != nil {
+		return err
+	}
+	if len(contents) == 0 && !f.Empty {
+		return nil
+	}
+	header := *headerTemplate
+	header.Typeflag = tar.TypeReg
+	header.Name = f.targetName
+	header.Size = int64(len(contents))
+	header.Mode = int64(f.Perm &^ umask)
+	if err := w.WriteHeader(&header); err != nil {
+		return nil
+	}
+	_, err = w.Write(contents)
+	return err
+}
diff --git a/lib/chezmoi/fs_actuator.go b/lib/chezmoi/fs_mutator.go
similarity index 68%
rename from lib/chezmoi/fs_actuator.go
rename to lib/chezmoi/fs_mutator.go
index e428a0e8f..3c505d0ad 100644
--- a/lib/chezmoi/fs_actuator.go
+++ b/lib/chezmoi/fs_mutator.go
@@ -7,27 +7,27 @@ import (
 	vfs "github.com/twpayne/go-vfs"
 )
 
-// An FSActuator makes changes to an vfs.FS.
-type FSActuator struct {
+// An FSMutator makes changes to an vfs.FS.
+type FSMutator struct {
 	vfs.FS
 	dir string
 }
 
-// NewFSActuator returns an actuator that acts on fs.
-func NewFSActuator(fs vfs.FS, targetDir string) *FSActuator {
+// NewFSMutator returns an mutator that acts on fs.
+func NewFSMutator(fs vfs.FS, targetDir string) *FSMutator {
 	var dir string
 	// Special case: if writing to the real filesystem, use github.com/google/renameio
 	if fs == vfs.OSFS {
 		dir = renameio.TempDir(targetDir)
 	}
-	return &FSActuator{
+	return &FSMutator{
 		FS:  fs,
 		dir: dir,
 	}
 }
 
-// WriteFile implements Actuator.WriteFile.
-func (a *FSActuator) WriteFile(name string, data []byte, perm os.FileMode, currData []byte) error {
+// WriteFile implements Mutator.WriteFile.
+func (a *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 {
 		t, err := renameio.TempFile(a.dir, name)
@@ -48,8 +48,8 @@ func (a *FSActuator) WriteFile(name string, data []byte, perm os.FileMode, currD
 	return a.FS.WriteFile(name, data, perm)
 }
 
-// WriteSymlink implements Actuator.WriteSymlink.
-func (a *FSActuator) WriteSymlink(oldname, newname string) error {
+// WriteSymlink implements Mutator.WriteSymlink.
+func (a *FSMutator) WriteSymlink(oldname, newname string) error {
 	// Special case: if writing to the real filesystem, use github.com/google/renameio
 	if a.FS == vfs.OSFS {
 		return renameio.Symlink(oldname, newname)
diff --git a/lib/chezmoi/logging_actuator.go b/lib/chezmoi/logging_actuator.go
deleted file mode 100644
index 47f53b320..000000000
--- a/lib/chezmoi/logging_actuator.go
+++ /dev/null
@@ -1,107 +0,0 @@
-package chezmoi
-
-import (
-	"fmt"
-	"io"
-	"os"
-	"strings"
-)
-
-// A LoggingActuator wraps an Actuator and logs all of the actions it executes
-// and any errors.
-type LoggingActuator struct {
-	w io.Writer
-	a Actuator
-}
-
-// NewLoggingActuator returns a new LoggingActuator.
-func NewLoggingActuator(w io.Writer, a Actuator) *LoggingActuator {
-	return &LoggingActuator{
-		a: a,
-		w: w,
-	}
-}
-
-// Chmod implements Actuator.Chmod.
-func (a *LoggingActuator) Chmod(name string, mode os.FileMode) error {
-	action := fmt.Sprintf("chmod %o %s", mode, name)
-	err := a.a.Chmod(name, mode)
-	if err == nil {
-		_, _ = fmt.Fprintln(a.w, action)
-	} else {
-		_, _ = fmt.Fprintf(a.w, "%s: %v\n", action, err)
-	}
-	return err
-}
-
-// Mkdir implements Actuator.Mkdir.
-func (a *LoggingActuator) Mkdir(name string, perm os.FileMode) error {
-	action := fmt.Sprintf("mkdir -m %o %s", perm, name)
-	err := a.a.Mkdir(name, perm)
-	if err == nil {
-		_, _ = fmt.Fprintln(a.w, action)
-	} else {
-		_, _ = fmt.Fprintf(a.w, "%s: %v\n", action, err)
-	}
-	return err
-}
-
-// RemoveAll implements Actuator.RemoveAll.
-func (a *LoggingActuator) RemoveAll(name string) error {
-	action := fmt.Sprintf("rm -rf %s", name)
-	err := a.a.RemoveAll(name)
-	if err == nil {
-		_, _ = fmt.Fprintln(a.w, action)
-	} else {
-		_, _ = fmt.Fprintf(a.w, "%s: %v\n", action, err)
-	}
-	return err
-}
-
-// Rename implements Actuator.Rename.
-func (a *LoggingActuator) Rename(oldpath, newpath string) error {
-	action := fmt.Sprintf("mv %s %s", oldpath, newpath)
-	err := a.a.Rename(oldpath, newpath)
-	if err == nil {
-		_, _ = fmt.Fprintln(a.w, action)
-	} else {
-		_, _ = fmt.Fprintf(a.w, "%s: %v\n", action, err)
-	}
-	return err
-}
-
-// WriteFile implements Actuator.WriteFile.
-func (a *LoggingActuator) WriteFile(name string, data []byte, perm os.FileMode, currData []byte) error {
-	action := fmt.Sprintf("install -m %o /dev/null %s", perm, name)
-	err := a.a.WriteFile(name, data, perm, currData)
-	if err == nil {
-		_, _ = fmt.Fprintln(a.w, action)
-		for _, section := range diff(splitLines(currData), splitLines(data)) {
-			for _, s := range section.s {
-				_, _ = fmt.Fprintf(a.w, "%c%s\n", section.ctype, s)
-			}
-		}
-	} else {
-		_, _ = fmt.Fprintf(a.w, "%s: %v\n", action, err)
-	}
-	return err
-}
-
-// WriteSymlink implements Actuator.WriteSymlink.
-func (a *LoggingActuator) WriteSymlink(oldname, newname string) error {
-	action := fmt.Sprintf("ln -sf %s %s", oldname, newname)
-	err := a.a.WriteSymlink(oldname, newname)
-	if err == nil {
-		_, _ = fmt.Fprintln(a.w, action)
-	} else {
-		_, _ = fmt.Fprintf(a.w, "%s: %v\n", action, err)
-	}
-	return err
-}
-
-func splitLines(data []byte) []string {
-	if len(data) == 0 {
-		return nil
-	}
-	return strings.Split(string(data), "\n")
-}
diff --git a/lib/chezmoi/logging_mutator.go b/lib/chezmoi/logging_mutator.go
new file mode 100644
index 000000000..c87765c8a
--- /dev/null
+++ b/lib/chezmoi/logging_mutator.go
@@ -0,0 +1,107 @@
+package chezmoi
+
+import (
+	"fmt"
+	"io"
+	"os"
+	"strings"
+)
+
+// A LoggingMutator wraps an Mutator and logs all of the actions it executes
+// and any errors.
+type LoggingMutator struct {
+	m Mutator
+	w io.Writer
+}
+
+// NewLoggingMutator returns a new LoggingMutator.
+func NewLoggingMutator(w io.Writer, m Mutator) *LoggingMutator {
+	return &LoggingMutator{
+		m: m,
+		w: w,
+	}
+}
+
+// Chmod implements Mutator.Chmod.
+func (m *LoggingMutator) Chmod(name string, mode os.FileMode) error {
+	action := fmt.Sprintf("chmod %o %s", mode, name)
+	err := m.m.Chmod(name, mode)
+	if err == nil {
+		_, _ = fmt.Fprintln(m.w, action)
+	} else {
+		_, _ = fmt.Fprintf(m.w, "%s: %v\n", action, err)
+	}
+	return err
+}
+
+// Mkdir implements Mutator.Mkdir.
+func (m *LoggingMutator) 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 {
+		_, _ = fmt.Fprintln(m.w, action)
+	} else {
+		_, _ = fmt.Fprintf(m.w, "%s: %v\n", action, err)
+	}
+	return err
+}
+
+// RemoveAll implements Mutator.RemoveAll.
+func (m *LoggingMutator) RemoveAll(name string) error {
+	action := fmt.Sprintf("rm -rf %s", name)
+	err := m.m.RemoveAll(name)
+	if err == nil {
+		_, _ = fmt.Fprintln(m.w, action)
+	} else {
+		_, _ = fmt.Fprintf(m.w, "%s: %v\n", action, err)
+	}
+	return err
+}
+
+// Rename implements Mutator.Rename.
+func (m *LoggingMutator) Rename(oldpath, newpath string) error {
+	action := fmt.Sprintf("mv %s %s", oldpath, newpath)
+	err := m.m.Rename(oldpath, newpath)
+	if err == nil {
+		_, _ = fmt.Fprintln(m.w, action)
+	} else {
+		_, _ = fmt.Fprintf(m.w, "%s: %v\n", action, err)
+	}
+	return err
+}
+
+// WriteFile implements Mutator.WriteFile.
+func (m *LoggingMutator) 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 {
+		_, _ = fmt.Fprintln(m.w, action)
+		for _, section := range diff(splitLines(currData), splitLines(data)) {
+			for _, s := range section.s {
+				_, _ = fmt.Fprintf(m.w, "%c%s\n", section.ctype, s)
+			}
+		}
+	} else {
+		_, _ = fmt.Fprintf(m.w, "%s: %v\n", action, err)
+	}
+	return err
+}
+
+// WriteSymlink implements Mutator.WriteSymlink.
+func (m *LoggingMutator) WriteSymlink(oldname, newname string) error {
+	action := fmt.Sprintf("ln -sf %s %s", oldname, newname)
+	err := m.m.WriteSymlink(oldname, newname)
+	if err == nil {
+		_, _ = fmt.Fprintln(m.w, action)
+	} else {
+		_, _ = fmt.Fprintf(m.w, "%s: %v\n", action, err)
+	}
+	return err
+}
+
+func splitLines(data []byte) []string {
+	if len(data) == 0 {
+		return nil
+	}
+	return strings.Split(string(data), "\n")
+}
diff --git a/lib/chezmoi/actuator.go b/lib/chezmoi/mutator.go
similarity index 84%
rename from lib/chezmoi/actuator.go
rename to lib/chezmoi/mutator.go
index 5fa5fc6d9..e2bd29213 100644
--- a/lib/chezmoi/actuator.go
+++ b/lib/chezmoi/mutator.go
@@ -2,8 +2,8 @@ package chezmoi
 
 import "os"
 
-// An Actuator makes changes.
-type Actuator interface {
+// An Mutator makes changes.
+type Mutator interface {
 	Chmod(name string, mode os.FileMode) error
 	Mkdir(name string, perm os.FileMode) error
 	RemoveAll(name string) error
diff --git a/lib/chezmoi/null_actuator.go b/lib/chezmoi/null_actuator.go
deleted file mode 100644
index 7dee5308f..000000000
--- a/lib/chezmoi/null_actuator.go
+++ /dev/null
@@ -1,38 +0,0 @@
-package chezmoi
-
-import "os"
-
-type nullActuator struct{}
-
-// NullActuator is an Actuator that does nothing.
-var NullActuator nullActuator
-
-// Chmod implements Actuator.Chmod.
-func (nullActuator) Chmod(string, os.FileMode) error {
-	return nil
-}
-
-// Mkdir implements Actuator.Mkdir.
-func (nullActuator) Mkdir(string, os.FileMode) error {
-	return nil
-}
-
-// RemoveAll implements Actuator.RemoveAll.
-func (nullActuator) RemoveAll(string) error {
-	return nil
-}
-
-// Rename implements Actuator.Rename.
-func (nullActuator) Rename(string, string) error {
-	return nil
-}
-
-// WriteFile implements Actuator.WriteFile.
-func (nullActuator) WriteFile(string, []byte, os.FileMode, []byte) error {
-	return nil
-}
-
-// WriteSymlink implements Actuator.WriteSymlink.
-func (nullActuator) WriteSymlink(string, string) error {
-	return nil
-}
diff --git a/lib/chezmoi/null_mutator.go b/lib/chezmoi/null_mutator.go
new file mode 100644
index 000000000..4f8d231cf
--- /dev/null
+++ b/lib/chezmoi/null_mutator.go
@@ -0,0 +1,38 @@
+package chezmoi
+
+import "os"
+
+type nullMutator struct{}
+
+// NullMutator is an Mutator that does nothing.
+var NullMutator nullMutator
+
+// Chmod implements Mutator.Chmod.
+func (nullMutator) Chmod(string, os.FileMode) error {
+	return nil
+}
+
+// Mkdir implements Mutator.Mkdir.
+func (nullMutator) Mkdir(string, os.FileMode) error {
+	return nil
+}
+
+// RemoveAll implements Mutator.RemoveAll.
+func (nullMutator) RemoveAll(string) error {
+	return nil
+}
+
+// Rename implements Mutator.Rename.
+func (nullMutator) Rename(string, string) error {
+	return nil
+}
+
+// WriteFile implements Mutator.WriteFile.
+func (nullMutator) WriteFile(string, []byte, os.FileMode, []byte) error {
+	return nil
+}
+
+// WriteSymlink implements Mutator.WriteSymlink.
+func (nullMutator) WriteSymlink(string, string) error {
+	return nil
+}
diff --git a/lib/chezmoi/symlink.go b/lib/chezmoi/symlink.go
new file mode 100644
index 000000000..c5f6f94ce
--- /dev/null
+++ b/lib/chezmoi/symlink.go
@@ -0,0 +1,105 @@
+package chezmoi
+
+import (
+	"archive/tar"
+	"os"
+	"path/filepath"
+
+	vfs "github.com/twpayne/go-vfs"
+)
+
+// A Symlink represents the target state of a symlink.
+type Symlink struct {
+	sourceName       string
+	targetName       string
+	Template         bool
+	linkName         string
+	linkNameErr      error
+	evaluateLinkName func() (string, error)
+}
+
+type symlinkConcreteValue struct {
+	Type       string `json:"type" yaml:"type"`
+	SourcePath string `json:"sourcePath" yaml:"sourcePath"`
+	TargetPath string `json:"targetPath" yaml:"targetPath"`
+	Template   bool   `json:"template" yaml:"template"`
+	LinkName   string `json:"linkName" yaml:"linkName"`
+}
+
+// Apply ensures that the state of s's target in fs matches s.
+func (s *Symlink) Apply(fs vfs.FS, targetDir string, umask os.FileMode, mutator Mutator) error {
+	target, err := s.LinkName()
+	if err != nil {
+		return err
+	}
+	targetPath := filepath.Join(targetDir, s.targetName)
+	info, err := fs.Lstat(targetPath)
+	switch {
+	case err == nil && info.Mode()&os.ModeType == os.ModeSymlink:
+		currentTarget, err := fs.Readlink(targetPath)
+		if err != nil {
+			return err
+		}
+		if currentTarget == target {
+			return nil
+		}
+	case err == nil:
+	case os.IsNotExist(err):
+	default:
+		return err
+	}
+	return mutator.WriteSymlink(target, targetPath)
+}
+
+// ConcreteValue implements Entry.ConcreteValue.
+func (s *Symlink) ConcreteValue(targetDir, sourceDir string, recursive bool) (interface{}, error) {
+	linkName, err := s.LinkName()
+	if err != nil {
+		return nil, err
+	}
+	return &symlinkConcreteValue{
+		Type:       "symlink",
+		SourcePath: filepath.Join(sourceDir, s.SourceName()),
+		TargetPath: filepath.Join(targetDir, s.TargetName()),
+		Template:   s.Template,
+		LinkName:   linkName,
+	}, nil
+}
+
+// Evaluate evaluates s's target.
+func (s *Symlink) Evaluate() error {
+	_, err := s.LinkName()
+	return err
+}
+
+// SourceName implements Entry.SourceName.
+func (s *Symlink) SourceName() string {
+	return s.sourceName
+}
+
+// LinkName returns s's link name.
+func (s *Symlink) LinkName() (string, error) {
+	if s.evaluateLinkName != nil {
+		s.linkName, s.linkNameErr = s.evaluateLinkName()
+		s.evaluateLinkName = nil
+	}
+	return s.linkName, s.linkNameErr
+}
+
+// TargetName implements Entry.TargetName.
+func (s *Symlink) TargetName() string {
+	return s.targetName
+}
+
+// archive writes s to w.
+func (s *Symlink) archive(w *tar.Writer, headerTemplate *tar.Header, umask os.FileMode) error {
+	linkName, err := s.LinkName()
+	if err != nil {
+		return err
+	}
+	header := *headerTemplate
+	header.Name = s.targetName
+	header.Typeflag = tar.TypeSymlink
+	header.Linkname = linkName
+	return w.WriteHeader(&header)
+}
diff --git a/lib/chezmoi/target_state.go b/lib/chezmoi/target_state.go
new file mode 100644
index 000000000..2fe7e4a8b
--- /dev/null
+++ b/lib/chezmoi/target_state.go
@@ -0,0 +1,568 @@
+package chezmoi
+
+import (
+	"archive/tar"
+	"bytes"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"os"
+	"os/user"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"text/template"
+	"time"
+
+	vfs "github.com/twpayne/go-vfs"
+)
+
+// A TargetState represents the root target state.
+type TargetState struct {
+	TargetDir string
+	Umask     os.FileMode
+	SourceDir string
+	Data      map[string]interface{}
+	Funcs     template.FuncMap
+	Entries   map[string]Entry
+}
+
+// NewTargetState creates a new TargetState.
+func NewTargetState(targetDir string, umask os.FileMode, sourceDir string, data map[string]interface{}, funcs template.FuncMap) *TargetState {
+	return &TargetState{
+		TargetDir: targetDir,
+		Umask:     umask,
+		SourceDir: sourceDir,
+		Data:      data,
+		Funcs:     funcs,
+		Entries:   make(map[string]Entry),
+	}
+}
+
+// Add adds a new target to ts.
+func (ts *TargetState) Add(fs vfs.FS, targetPath string, info os.FileInfo, addEmpty, addTemplate bool, mutator Mutator) error {
+	if !filepath.HasPrefix(targetPath, ts.TargetDir) {
+		return fmt.Errorf("%s: outside target directory", targetPath)
+	}
+	targetName, err := filepath.Rel(ts.TargetDir, targetPath)
+	if err != nil {
+		return err
+	}
+	if info == nil {
+		var err error
+		info, err = fs.Lstat(targetPath)
+		if err != nil {
+			return err
+		}
+	}
+
+	// Add the parent directories, if needed.
+	parentDirSourceName := ""
+	entries := ts.Entries
+	if parentDirName := filepath.Dir(targetName); parentDirName != "." {
+		parentEntry, err := ts.findEntry(parentDirName)
+		if err != nil && !os.IsNotExist(err) {
+			return err
+		}
+		if parentEntry == nil {
+			if err := ts.Add(fs, filepath.Join(ts.TargetDir, parentDirName), nil, false, false, mutator); err != nil {
+				return err
+			}
+			parentEntry, err = ts.findEntry(parentDirName)
+			if err != nil {
+				return err
+			}
+		} else if _, ok := parentEntry.(*Dir); !ok {
+			return fmt.Errorf("%s: not a directory", parentDirName)
+		}
+		parentDir := parentEntry.(*Dir)
+		parentDirSourceName = parentDir.sourceName
+		entries = parentDir.Entries
+	}
+
+	name := filepath.Base(targetName)
+	switch {
+	case info.Mode().IsDir():
+		if entry, ok := entries[name]; ok {
+			if _, ok := entry.(*Dir); !ok {
+				return fmt.Errorf("%s: already added and not a directory", targetName)
+			}
+			return nil // entry already exists
+		}
+		sourceName := ParsedSourceDirName{
+			DirName: name,
+			Perm:    info.Mode() & os.ModePerm,
+		}.SourceDirName()
+		if parentDirSourceName != "" {
+			sourceName = filepath.Join(parentDirSourceName, sourceName)
+		}
+		if err := mutator.Mkdir(filepath.Join(ts.SourceDir, sourceName), 0777&^ts.Umask); err != nil {
+			return err
+		}
+		// If the directory is empty, add a .keep file so the directory is
+		// managed by git. Chezmoi will ignore the .keep file as it begins with
+		// a dot.
+		infos, err := fs.ReadDir(targetPath)
+		if err != nil {
+			return err
+		}
+		if len(infos) == 0 {
+			if err := mutator.WriteFile(filepath.Join(ts.SourceDir, sourceName, ".keep"), nil, 0666&^ts.Umask, nil); err != nil {
+				return err
+			}
+		}
+		entries[name] = newDir(sourceName, targetName, info.Mode()&os.ModePerm)
+	case info.Mode().IsRegular():
+		if entry, ok := entries[name]; ok {
+			if _, ok := entry.(*File); !ok {
+				return fmt.Errorf("%s: already added and not a regular file", targetName)
+			}
+			return nil // entry already exists
+		}
+		if info.Size() == 0 && !addEmpty {
+			return nil
+		}
+		sourceName := ParsedSourceFileName{
+			FileName: name,
+			Mode:     info.Mode() & os.ModePerm,
+			Empty:    info.Size() == 0,
+			Template: addTemplate,
+		}.SourceFileName()
+		if parentDirSourceName != "" {
+			sourceName = filepath.Join(parentDirSourceName, sourceName)
+		}
+		data, err := fs.ReadFile(targetPath)
+		if err != nil {
+			return err
+		}
+		if addTemplate {
+			data = autoTemplate(data, ts.Data)
+		}
+		if err := mutator.WriteFile(filepath.Join(ts.SourceDir, sourceName), data, 0666&^ts.Umask, nil); err != nil {
+			return err
+		}
+		entries[name] = &File{
+			sourceName: sourceName,
+			targetName: targetName,
+			Empty:      len(data) == 0,
+			Perm:       info.Mode() & os.ModePerm,
+			Template:   addTemplate,
+			contents:   data,
+		}
+	case info.Mode()&os.ModeType == os.ModeSymlink:
+		if entry, ok := entries[name]; ok {
+			if _, ok := entry.(*Symlink); !ok {
+				return fmt.Errorf("%s: already added and not a symlink", targetName)
+			}
+			return nil // entry already exists
+		}
+		sourceName := ParsedSourceFileName{
+			FileName: name,
+			Mode:     os.ModeSymlink,
+		}.SourceFileName()
+		if parentDirSourceName != "" {
+			sourceName = filepath.Join(parentDirSourceName, sourceName)
+		}
+		data, err := fs.Readlink(targetPath)
+		if err != nil {
+			return err
+		}
+		if err := mutator.WriteFile(filepath.Join(ts.SourceDir, sourceName), []byte(data), 0666&^ts.Umask, nil); err != nil {
+			return err
+		}
+		entries[name] = &Symlink{
+			sourceName: sourceName,
+			targetName: targetName,
+			linkName:   data,
+		}
+	default:
+		return fmt.Errorf("%s: not a regular file or directory", targetName)
+	}
+	return nil
+}
+
+// Archive writes ts to w.
+func (ts *TargetState) Archive(w *tar.Writer, umask os.FileMode) error {
+	currentUser, err := user.Current()
+	if err != nil {
+		return err
+	}
+	uid, err := strconv.Atoi(currentUser.Uid)
+	if err != nil {
+		return err
+	}
+	gid, err := strconv.Atoi(currentUser.Gid)
+	if err != nil {
+		return err
+	}
+	group, err := user.LookupGroupId(currentUser.Gid)
+	if err != nil {
+		return err
+	}
+	now := time.Now()
+	headerTemplate := tar.Header{
+		Uid:        uid,
+		Gid:        gid,
+		Uname:      currentUser.Username,
+		Gname:      group.Name,
+		ModTime:    now,
+		AccessTime: now,
+		ChangeTime: now,
+	}
+	for _, entryName := range sortedEntryNames(ts.Entries) {
+		if err := ts.Entries[entryName].archive(w, &headerTemplate, umask); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// Apply ensures that ts.TargetDir in fs matches ts.
+func (ts *TargetState) Apply(fs vfs.FS, mutator Mutator) error {
+	for _, entryName := range sortedEntryNames(ts.Entries) {
+		if err := ts.Entries[entryName].Apply(fs, ts.TargetDir, ts.Umask, mutator); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// ConcreteValue returns a value suitable for serialization.
+func (ts *TargetState) ConcreteValue(recursive bool) (interface{}, error) {
+	var entryConcreteValues []interface{}
+	for _, entryName := range sortedEntryNames(ts.Entries) {
+		entryConcreteValue, err := ts.Entries[entryName].ConcreteValue(ts.TargetDir, ts.SourceDir, recursive)
+		if err != nil {
+			return nil, err
+		}
+		entryConcreteValues = append(entryConcreteValues, entryConcreteValue)
+	}
+	return entryConcreteValues, nil
+}
+
+// Evaluate evaluates all of the entries in ts.
+func (ts *TargetState) Evaluate() error {
+	for _, entryName := range sortedEntryNames(ts.Entries) {
+		if err := ts.Entries[entryName].Evaluate(); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// Get returns the state of the given target, or nil if no such target is found.
+func (ts *TargetState) Get(target string) (Entry, error) {
+	if !filepath.HasPrefix(target, ts.TargetDir) {
+		return nil, fmt.Errorf("%s: outside target directory", target)
+	}
+	targetName, err := filepath.Rel(ts.TargetDir, target)
+	if err != nil {
+		return nil, err
+	}
+	return ts.findEntry(targetName)
+}
+
+// Import imports an archive.
+func (ts *TargetState) Import(r *tar.Reader, destinationDir string, stripComponents int, mutator Mutator) error {
+	for {
+		header, err := r.Next()
+		if err == io.EOF {
+			break
+		} else if err != nil {
+			return err
+		}
+		switch header.Typeflag {
+		case tar.TypeDir, tar.TypeReg, tar.TypeSymlink:
+			if err := ts.importHeader(r, header, destinationDir, stripComponents, mutator); err != nil {
+				return err
+			}
+		case tar.TypeXGlobalHeader:
+		default:
+			return fmt.Errorf("%s: unspported typeflag '%c'", header.Name, header.Typeflag)
+		}
+	}
+	return nil
+}
+
+// Populate walks fs from ts.SourceDir to populate ts.
+func (ts *TargetState) Populate(fs vfs.FS) error {
+	return vfs.Walk(fs, ts.SourceDir, func(path string, info os.FileInfo, _ error) error {
+		relPath, err := filepath.Rel(ts.SourceDir, path)
+		if err != nil {
+			return err
+		}
+		if relPath == "." {
+			return nil
+		}
+		// Ignore all files and directories beginning with "."
+		if _, name := filepath.Split(relPath); strings.HasPrefix(name, ".") {
+			if info.IsDir() {
+				return filepath.SkipDir
+			}
+			return nil
+		}
+		switch {
+		case info.Mode().IsDir():
+			components := splitPathList(relPath)
+			dirNames, perms := parseDirNameComponents(components)
+			targetName := filepath.Join(dirNames...)
+			entries, err := ts.findEntries(dirNames[:len(dirNames)-1])
+			if err != nil {
+				return err
+			}
+			dirName := dirNames[len(dirNames)-1]
+			perm := perms[len(perms)-1]
+			entries[dirName] = newDir(relPath, targetName, perm)
+		case info.Mode().IsRegular():
+			psfp := parseSourceFilePath(relPath)
+			entries, err := ts.findEntries(psfp.dirNames)
+			if err != nil {
+				return err
+			}
+
+			targetName := filepath.Join(append(psfp.dirNames, psfp.FileName)...)
+			var entry Entry
+			switch psfp.Mode & os.ModeType {
+			case 0:
+				evaluateContents := func() ([]byte, error) {
+					return fs.ReadFile(path)
+				}
+				if psfp.Template {
+					evaluateContents = func() ([]byte, error) {
+						return ts.executeTemplate(fs, path)
+					}
+				}
+				entry = &File{
+					sourceName:       relPath,
+					targetName:       targetName,
+					Empty:            psfp.Empty,
+					Perm:             psfp.Mode & os.ModePerm,
+					Template:         psfp.Template,
+					evaluateContents: evaluateContents,
+				}
+			case os.ModeSymlink:
+				evaluateLinkName := func() (string, error) {
+					data, err := fs.ReadFile(path)
+					return string(data), err
+				}
+				if psfp.Template {
+					evaluateLinkName = func() (string, error) {
+						data, err := ts.executeTemplate(fs, path)
+						return string(data), err
+					}
+				}
+				entry = &Symlink{
+					sourceName:       relPath,
+					targetName:       targetName,
+					Template:         psfp.Template,
+					evaluateLinkName: evaluateLinkName,
+				}
+			default:
+				return fmt.Errorf("%v: unsupported mode: %d", path, psfp.Mode&os.ModeType)
+			}
+			entries[psfp.FileName] = entry
+		default:
+			return fmt.Errorf("unsupported file type: %s", path)
+		}
+		return nil
+	})
+}
+
+func (ts *TargetState) executeTemplate(fs vfs.FS, path string) ([]byte, error) {
+	data, err := fs.ReadFile(path)
+	if err != nil {
+		return nil, err
+	}
+	return ts.executeTemplateData(path, data)
+}
+
+func (ts *TargetState) executeTemplateData(name string, data []byte) (_ []byte, err error) {
+	tmpl, err := template.New(name).Option("missingkey=error").Funcs(ts.Funcs).Parse(string(data))
+	if err != nil {
+		return nil, fmt.Errorf("%s: %v", name, err)
+	}
+	defer func() {
+		if r := recover(); r != nil {
+			if tfe, ok := r.(templateFuncError); ok {
+				err = tfe.err
+			} else {
+				panic(r)
+			}
+		}
+	}()
+	output := &bytes.Buffer{}
+	if err = tmpl.Execute(output, ts.Data); err != nil {
+		return nil, fmt.Errorf("%s: %v", name, err)
+	}
+	return output.Bytes(), nil
+}
+
+func (ts *TargetState) findEntries(dirNames []string) (map[string]Entry, error) {
+	entries := ts.Entries
+	for i, dirName := range dirNames {
+		if entry, ok := entries[dirName]; !ok {
+			return nil, os.ErrNotExist
+		} else if dir, ok := entry.(*Dir); ok {
+			entries = dir.Entries
+		} else {
+			return nil, fmt.Errorf("%s: not a directory", filepath.Join(dirNames[:i+1]...))
+		}
+	}
+	return entries, nil
+}
+
+func (ts *TargetState) findEntry(name string) (Entry, error) {
+	names := splitPathList(name)
+	entries, err := ts.findEntries(names[:len(names)-1])
+	if err != nil {
+		return nil, err
+	}
+	return entries[names[len(names)-1]], nil
+}
+
+func (ts *TargetState) importHeader(r io.Reader, header *tar.Header, destinationDir string, stripComponents int, mutator Mutator) error {
+	targetPath := header.Name
+	if stripComponents > 0 {
+		targetPath = filepath.Join(strings.Split(targetPath, string(os.PathSeparator))[stripComponents:]...)
+	}
+	if destinationDir != "" {
+		targetPath = filepath.Join(destinationDir, targetPath)
+	} else {
+		targetPath = filepath.Join(ts.TargetDir, targetPath)
+	}
+	targetName, err := filepath.Rel(ts.TargetDir, targetPath)
+	if err != nil {
+		return err
+	}
+	parentDirSourceName := ""
+	entries := ts.Entries
+	if parentDirName := filepath.Dir(targetName); parentDirName != "." {
+		parentEntry, err := ts.findEntry(parentDirName)
+		if err != nil {
+			return err
+		}
+		parentDir, ok := parentEntry.(*Dir)
+		if !ok {
+			return fmt.Errorf("%s: parent is not a directory", targetName)
+		}
+		parentDirSourceName = parentDir.sourceName
+		entries = parentDir.Entries
+	}
+	name := filepath.Base(targetName)
+	switch header.Typeflag {
+	case tar.TypeDir:
+		var existingDir *Dir
+		if entry, ok := entries[name]; ok {
+			existingDir, ok = entry.(*Dir)
+			if !ok {
+				return fmt.Errorf("%s: already added and not a directory", targetName)
+			}
+		}
+		perm := os.FileMode(header.Mode) & os.ModePerm
+		sourceName := ParsedSourceDirName{
+			DirName: name,
+			Perm:    perm,
+		}.SourceDirName()
+		if parentDirSourceName != "" {
+			sourceName = filepath.Join(parentDirSourceName, sourceName)
+		}
+		dir := newDir(sourceName, targetName, perm)
+		if existingDir != nil {
+			if existingDir.sourceName == dir.sourceName {
+				return nil
+			}
+			return mutator.Rename(filepath.Join(ts.SourceDir, existingDir.sourceName), filepath.Join(ts.SourceDir, dir.sourceName))
+		}
+		// FIXME Add a .keep file if the directory is empty
+		entries[name] = dir
+		return mutator.Mkdir(filepath.Join(ts.SourceDir, sourceName), 0777&^ts.Umask)
+	case tar.TypeReg:
+		var existingFile *File
+		var existingContents []byte
+		if entry, ok := entries[name]; ok {
+			existingFile, ok = entry.(*File)
+			if !ok {
+				return fmt.Errorf("%s: already added and not a regular file", targetName)
+			}
+			existingContents, err = existingFile.Contents()
+			if err != nil {
+				return err
+			}
+		}
+		perm := os.FileMode(header.Mode) & os.ModePerm
+		empty := header.Size == 0
+		sourceName := ParsedSourceFileName{
+			FileName: name,
+			Mode:     perm,
+			Empty:    empty,
+		}.SourceFileName()
+		if parentDirSourceName != "" {
+			sourceName = filepath.Join(parentDirSourceName, sourceName)
+		}
+		contents, err := ioutil.ReadAll(r)
+		if err != nil {
+			return err
+		}
+		file := &File{
+			sourceName: sourceName,
+			targetName: targetName,
+			Empty:      empty,
+			Perm:       perm,
+			Template:   false,
+			contents:   contents,
+		}
+		if existingFile != nil {
+			if bytes.Equal(existingFile.contents, file.contents) {
+				if existingFile.sourceName == file.sourceName {
+					return nil
+				}
+				return mutator.Rename(filepath.Join(ts.SourceDir, existingFile.sourceName), filepath.Join(ts.SourceDir, file.sourceName))
+			}
+			if err := mutator.RemoveAll(filepath.Join(ts.SourceDir, existingFile.sourceName)); err != nil {
+				return err
+			}
+		}
+		entries[name] = file
+		return mutator.WriteFile(filepath.Join(ts.SourceDir, sourceName), contents, 0666&^ts.Umask, existingContents)
+	case tar.TypeSymlink:
+		var existingSymlink *Symlink
+		var existingLinkName string
+		if entry, ok := entries[name]; ok {
+			existingSymlink, ok = entry.(*Symlink)
+			if !ok {
+				return fmt.Errorf("%s: already added and not a symlink", targetName)
+			}
+			existingLinkName, err = existingSymlink.LinkName()
+			if err != nil {
+				return err
+			}
+		}
+		sourceName := ParsedSourceFileName{
+			FileName: name,
+			Mode:     os.ModeSymlink,
+		}.SourceFileName()
+		if parentDirSourceName != "" {
+			sourceName = filepath.Join(parentDirSourceName, sourceName)
+		}
+		symlink := &Symlink{
+			sourceName: sourceName,
+			targetName: targetName,
+			linkName:   header.Linkname,
+		}
+		if existingSymlink != nil {
+			if existingSymlink.linkName == symlink.linkName {
+				if existingSymlink.sourceName == symlink.sourceName {
+					return nil
+				}
+				return mutator.Rename(filepath.Join(ts.SourceDir, existingSymlink.sourceName), filepath.Join(ts.SourceDir, symlink.sourceName))
+			}
+			if err := mutator.RemoveAll(filepath.Join(ts.SourceDir, existingSymlink.sourceName)); err != nil {
+				return err
+			}
+		}
+		entries[name] = symlink
+		return mutator.WriteFile(filepath.Join(ts.SourceDir, symlink.sourceName), []byte(symlink.linkName), 0666&^ts.Umask, []byte(existingLinkName))
+	default:
+		return fmt.Errorf("%s: unspported typeflag '%c'", header.Name, header.Typeflag)
+	}
+}


Test patch:
diff --git a/lib/chezmoi/chezmoi_test.go b/lib/chezmoi/chezmoi_test.go
index 438f6eb56..fee9d46f7 100644
--- a/lib/chezmoi/chezmoi_test.go
+++ b/lib/chezmoi/chezmoi_test.go
@@ -475,7 +475,7 @@ func TestEndToEnd(t *testing.T) {
 			if err := ts.Populate(fs); err != nil {
 				t.Fatalf("ts.Populate(%+v) == %v, want <nil>", fs, err)
 			}
-			if err := ts.Apply(fs, NewLoggingActuator(os.Stderr, NewFSActuator(fs, tc.targetDir))); err != nil {
+			if err := ts.Apply(fs, NewLoggingMutator(os.Stderr, NewFSMutator(fs, tc.targetDir))); err != nil {
 				t.Fatalf("ts.Apply(fs, _) == %v, want <nil>", err)
 			}
 			vfst.RunTests(t, fs, "", tc.tests)