Repository: twpayne/chezmoi
Pull request #57 (2018-12-04 15:29:25)

Internal refactor
Apart from fixing a bug in the `remove` command, there should be no user-visible changes.


Patch:
diff --git a/cmd/add.go b/cmd/add.go
index 70ac3a36e..b7420f386 100644
--- a/cmd/add.go
+++ b/cmd/add.go
@@ -6,7 +6,7 @@ import (
 	"path/filepath"
 
 	"github.com/spf13/cobra"
-	"github.com/twpayne/go-vfs"
+	vfs "github.com/twpayne/go-vfs"
 )
 
 var addCommand = &cobra.Command{
diff --git a/cmd/apply.go b/cmd/apply.go
index afc8233f0..9fb32166e 100644
--- a/cmd/apply.go
+++ b/cmd/apply.go
@@ -2,7 +2,7 @@ package cmd
 
 import (
 	"github.com/spf13/cobra"
-	"github.com/twpayne/go-vfs"
+	vfs "github.com/twpayne/go-vfs"
 )
 
 var applyCommand = &cobra.Command{
diff --git a/cmd/cat.go b/cmd/cat.go
index 93f3f2adc..83a014cec 100644
--- a/cmd/cat.go
+++ b/cmd/cat.go
@@ -3,11 +3,10 @@ package cmd
 import (
 	"fmt"
 	"os"
-	"path/filepath"
 
 	"github.com/spf13/cobra"
 	"github.com/twpayne/chezmoi/lib/chezmoi"
-	"github.com/twpayne/go-vfs"
+	vfs "github.com/twpayne/go-vfs"
 )
 
 var catCommand = &cobra.Command{
@@ -26,21 +25,14 @@ func (c *Config) runCatCommand(fs vfs.FS, command *cobra.Command, args []string)
 	if err != nil {
 		return err
 	}
-	for _, arg := range args {
-		path, err := filepath.Abs(arg)
-		if err != nil {
-			return err
-		}
-		entry, err := targetState.Get(path)
-		if err != nil {
-			return err
-		}
-		if entry == nil {
-			return fmt.Errorf("%s: not found", arg)
-		}
+	entries, err := c.getEntries(targetState, args)
+	if err != nil {
+		return err
+	}
+	for i, entry := range entries {
 		f, ok := entry.(*chezmoi.File)
 		if !ok {
-			return fmt.Errorf("%s: not a regular file", arg)
+			return fmt.Errorf("%s: not a regular file", args[i])
 		}
 		contents, err := f.Contents()
 		if err != nil {
diff --git a/cmd/cd.go b/cmd/cd.go
index 651e35199..14e59d82d 100644
--- a/cmd/cd.go
+++ b/cmd/cd.go
@@ -4,8 +4,8 @@ import (
 	"os"
 
 	"github.com/spf13/cobra"
-	"github.com/twpayne/go-shell"
-	"github.com/twpayne/go-vfs"
+	shell "github.com/twpayne/go-shell"
+	vfs "github.com/twpayne/go-vfs"
 )
 
 var cdCommand = &cobra.Command{
diff --git a/cmd/chattr.go b/cmd/chattr.go
index 302f0de50..65f82b96e 100644
--- a/cmd/chattr.go
+++ b/cmd/chattr.go
@@ -14,7 +14,7 @@ import (
 
 	"github.com/spf13/cobra"
 	"github.com/twpayne/chezmoi/lib/chezmoi"
-	"github.com/twpayne/go-vfs"
+	vfs "github.com/twpayne/go-vfs"
 )
 
 var chattrCommand = &cobra.Command{
@@ -46,19 +46,12 @@ func (c *Config) runChattrCommand(fs vfs.FS, cmd *cobra.Command, args []string)
 	if err != nil {
 		return err
 	}
+	entries, err := c.getEntries(targetState, args[1:])
+	if err != nil {
+		return err
+	}
 	renames := make(map[string]string)
-	for _, arg := range args[1:] {
-		targetPath, err := filepath.Abs(arg)
-		if err != nil {
-			return err
-		}
-		entry, err := targetState.Get(targetPath)
-		if err != nil {
-			return err
-		}
-		if entry == nil {
-			return fmt.Errorf("%s: not under management", arg)
-		}
+	for _, entry := range entries {
 		oldSourceName := entry.SourceName()
 		var newSourceName string
 		switch entry := entry.(type) {
diff --git a/cmd/cmd.go b/cmd/cmd.go
index 3e113e44d..fd3e912f4 100644
--- a/cmd/cmd.go
+++ b/cmd/cmd.go
@@ -12,10 +12,10 @@ import (
 	"syscall"
 	"text/template"
 
-	"github.com/mitchellh/go-homedir"
+	homedir "github.com/mitchellh/go-homedir"
 	"github.com/spf13/cobra"
 	"github.com/twpayne/chezmoi/lib/chezmoi"
-	"github.com/twpayne/go-vfs"
+	vfs "github.com/twpayne/go-vfs"
 )
 
 // A Version represents a version.
@@ -60,19 +60,12 @@ func (c *Config) applyArgs(fs vfs.FS, args []string, actuator chezmoi.Actuator)
 	if len(args) == 0 {
 		return targetState.Apply(fs, actuator)
 	}
-	for _, arg := range args {
-		targetPath, err := filepath.Abs(arg)
-		if err != nil {
-			return err
-		}
-		entry, err := targetState.Get(targetPath)
-		if err != nil {
-			return err
-		}
-		if entry == nil {
-			return fmt.Errorf("%s: not under management", arg)
-		}
-		if err := targetState.ApplyOne(fs, targetPath, entry, actuator); err != nil {
+	entries, err := c.getEntries(targetState, args)
+	if err != nil {
+		return err
+	}
+	for _, entry := range entries {
+		if err := entry.Apply(fs, targetState.TargetDir, targetState.Umask, actuator); err != nil {
 			return err
 		}
 	}
@@ -96,7 +89,7 @@ func (c *Config) exec(argv []string) error {
 func (c *Config) getDefaultActuator(fs vfs.FS) chezmoi.Actuator {
 	var actuator chezmoi.Actuator
 	if c.DryRun {
-		actuator = chezmoi.NewNullActuator()
+		actuator = chezmoi.NullActuator
 	} else {
 		actuator = chezmoi.NewFSActuator(fs, c.TargetDir)
 	}
@@ -139,28 +132,23 @@ func getDefaultData() (map[string]interface{}, error) {
 	return data, nil
 }
 
-func (c *Config) getSourceNames(targetState *chezmoi.TargetState, targets []string) ([]string, error) {
-	sourceNames := []string{}
-	allEntries := targetState.AllEntries()
-	for _, target := range targets {
-		absTarget, err := filepath.Abs(target)
+func (c *Config) getEntries(targetState *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
 		}
-		targetName, err := filepath.Rel(c.TargetDir, absTarget)
+		entry, err := targetState.Get(targetPath)
 		if err != nil {
 			return nil, err
 		}
-		if filepath.HasPrefix(targetName, "..") {
-			return nil, fmt.Errorf("%s: not in target directory", target)
-		}
-		entry, ok := allEntries[targetName]
-		if !ok {
-			return nil, fmt.Errorf("%s: not found", targetName)
+		if entry == nil {
+			return nil, fmt.Errorf("%s: not under management", arg)
 		}
-		sourceNames = append(sourceNames, entry.SourceName())
+		entries = append(entries, entry)
 	}
-	return sourceNames, nil
+	return entries, nil
 }
 
 func (c *Config) getTargetState(fs vfs.FS) (*chezmoi.TargetState, error) {
diff --git a/cmd/diff.go b/cmd/diff.go
index 40ee99d68..ae1c6cb2d 100644
--- a/cmd/diff.go
+++ b/cmd/diff.go
@@ -5,7 +5,7 @@ import (
 
 	"github.com/spf13/cobra"
 	"github.com/twpayne/chezmoi/lib/chezmoi"
-	"github.com/twpayne/go-vfs"
+	vfs "github.com/twpayne/go-vfs"
 )
 
 var diffCommand = &cobra.Command{
@@ -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.NewNullActuator())
+	actuator := chezmoi.NewLoggingActuator(os.Stdout, chezmoi.NullActuator)
 	return c.applyArgs(fs, args, actuator)
 }
diff --git a/cmd/dump.go b/cmd/dump.go
index ac48a1fa8..6c7b16d42 100644
--- a/cmd/dump.go
+++ b/cmd/dump.go
@@ -1,12 +1,9 @@
 package cmd
 
 import (
-	"fmt"
-	"path/filepath"
-
 	"github.com/davecgh/go-spew/spew"
 	"github.com/spf13/cobra"
-	"github.com/twpayne/go-vfs"
+	vfs "github.com/twpayne/go-vfs"
 )
 
 var dumpCommand = &cobra.Command{
@@ -27,19 +24,15 @@ func (c *Config) runDumpCommand(fs vfs.FS, command *cobra.Command, args []string
 	if len(args) == 0 {
 		spew.Dump(targetState)
 	} else {
-		for _, arg := range args {
-			path, err := filepath.Abs(arg)
-			if err != nil {
-				return err
-			}
-			state, err := targetState.Get(path)
-			if err != nil {
+		entries, err := c.getEntries(targetState, args)
+		if err != nil {
+			return err
+		}
+		for _, entry := range entries {
+			if err := entry.Evaluate(); err != nil {
 				return err
 			}
-			if state == nil {
-				return fmt.Errorf("%s: not found", arg)
-			}
-			spew.Dump(state)
+			spew.Dump(entry)
 		}
 	}
 	return nil
diff --git a/cmd/edit.go b/cmd/edit.go
index ce1df36f3..734deb590 100644
--- a/cmd/edit.go
+++ b/cmd/edit.go
@@ -9,7 +9,7 @@ import (
 
 	"github.com/spf13/cobra"
 	"github.com/twpayne/chezmoi/lib/chezmoi"
-	"github.com/twpayne/go-vfs"
+	vfs "github.com/twpayne/go-vfs"
 )
 
 var editCommand = &cobra.Command{
@@ -42,7 +42,7 @@ func (c *Config) runEditCommand(fs vfs.FS, command *cobra.Command, args []string
 	if err != nil {
 		return err
 	}
-	sourceFileNames, err := c.getSourceNames(targetState, args)
+	entries, err := c.getEntries(targetState, args)
 	if err != nil {
 		return err
 	}
@@ -54,8 +54,8 @@ func (c *Config) runEditCommand(fs vfs.FS, command *cobra.Command, args []string
 		editor = "vi"
 	}
 	argv := []string{}
-	for _, sourceFileName := range sourceFileNames {
-		argv = append(argv, filepath.Join(c.SourceDir, sourceFileName))
+	for _, entry := range entries {
+		argv = append(argv, filepath.Join(c.SourceDir, entry.SourceName()))
 	}
 	if !c.edit.diff && !c.edit.apply {
 		return c.exec(append([]string{editor}, argv...))
@@ -71,29 +71,18 @@ func (c *Config) runEditCommand(fs vfs.FS, command *cobra.Command, args []string
 		return err
 	}
 	applyActuator := c.getDefaultActuator(fs)
-	for _, arg := range args {
-		targetPath, err := filepath.Abs(arg)
-		if err != nil {
-			return err
-		}
-		entry, err := targetState.Get(targetPath)
-		if err != nil {
-			return err
-		}
-		if entry == nil {
-			return fmt.Errorf("%s: not under management", arg)
-		}
-		anyActuator := chezmoi.NewAnyActuator(chezmoi.NewNullActuator())
+	for i, entry := range entries {
+		anyActuator := chezmoi.NewAnyActuator(chezmoi.NullActuator)
 		var actuator chezmoi.Actuator = anyActuator
 		if c.edit.diff {
 			actuator = chezmoi.NewLoggingActuator(os.Stdout, actuator)
 		}
-		if err := targetState.ApplyOne(fs, targetPath, entry, actuator); err != nil {
+		if err := entry.Apply(fs, targetState.TargetDir, targetState.Umask, actuator); err != nil {
 			return err
 		}
 		if c.edit.apply && anyActuator.Actuated() {
 			if c.edit.prompt {
-				choice, err := prompt(fmt.Sprintf("Apply %s", arg), "ynqa")
+				choice, err := prompt(fmt.Sprintf("Apply %s", args[i]), "ynqa")
 				if err != nil {
 					return err
 				}
@@ -107,7 +96,7 @@ func (c *Config) runEditCommand(fs vfs.FS, command *cobra.Command, args []string
 					c.edit.prompt = false
 				}
 			}
-			if err := targetState.ApplyOne(fs, targetPath, entry, applyActuator); err != nil {
+			if err := entry.Apply(fs, targetState.TargetDir, targetState.Umask, applyActuator); err != nil {
 				return err
 			}
 		}
diff --git a/cmd/forget.go b/cmd/forget.go
index fd218a883..3458e19fb 100644
--- a/cmd/forget.go
+++ b/cmd/forget.go
@@ -4,7 +4,7 @@ import (
 	"path/filepath"
 
 	"github.com/spf13/cobra"
-	"github.com/twpayne/go-vfs"
+	vfs "github.com/twpayne/go-vfs"
 )
 
 var forgetCommand = &cobra.Command{
@@ -23,13 +23,13 @@ func (c *Config) runForgetCommand(fs vfs.FS, command *cobra.Command, args []stri
 	if err != nil {
 		return err
 	}
-	sourceNames, err := c.getSourceNames(targetState, args)
+	entries, err := c.getEntries(targetState, args)
 	if err != nil {
 		return err
 	}
 	actuator := c.getDefaultActuator(fs)
-	for _, sourceName := range sourceNames {
-		if err := actuator.RemoveAll(filepath.Join(c.SourceDir, sourceName)); err != nil {
+	for _, entry := range entries {
+		if err := actuator.RemoveAll(filepath.Join(c.SourceDir, entry.SourceName())); err != nil {
 			return err
 		}
 	}
diff --git a/cmd/keyring.go b/cmd/keyring.go
index 4f962fc92..112387f3e 100644
--- a/cmd/keyring.go
+++ b/cmd/keyring.go
@@ -5,7 +5,7 @@ import (
 
 	"github.com/spf13/cobra"
 	"github.com/twpayne/chezmoi/lib/chezmoi"
-	"github.com/zalando/go-keyring"
+	keyring "github.com/zalando/go-keyring"
 )
 
 var keyringCommand = &cobra.Command{
diff --git a/cmd/keyring_get.go b/cmd/keyring_get.go
index 84cff962c..cc7b412fc 100644
--- a/cmd/keyring_get.go
+++ b/cmd/keyring_get.go
@@ -4,8 +4,8 @@ import (
 	"fmt"
 
 	"github.com/spf13/cobra"
-	"github.com/twpayne/go-vfs"
-	"github.com/zalando/go-keyring"
+	vfs "github.com/twpayne/go-vfs"
+	keyring "github.com/zalando/go-keyring"
 )
 
 var keyringGetCommand = &cobra.Command{
diff --git a/cmd/keyring_set.go b/cmd/keyring_set.go
index 14ada47f5..6016c3f4b 100644
--- a/cmd/keyring_set.go
+++ b/cmd/keyring_set.go
@@ -5,8 +5,8 @@ import (
 	"os"
 
 	"github.com/spf13/cobra"
-	"github.com/twpayne/go-vfs"
-	"github.com/zalando/go-keyring"
+	vfs "github.com/twpayne/go-vfs"
+	keyring "github.com/zalando/go-keyring"
 	"golang.org/x/crypto/ssh/terminal"
 )
 
diff --git a/cmd/lastpass.go b/cmd/lastpass.go
index 3ff964641..3782aacf6 100644
--- a/cmd/lastpass.go
+++ b/cmd/lastpass.go
@@ -8,7 +8,7 @@ import (
 
 	"github.com/spf13/cobra"
 	"github.com/twpayne/chezmoi/lib/chezmoi"
-	"github.com/twpayne/go-vfs"
+	vfs "github.com/twpayne/go-vfs"
 )
 
 var lastpassCommand = &cobra.Command{
@@ -17,6 +17,7 @@ var lastpassCommand = &cobra.Command{
 	RunE:  makeRunE(config.runLastPassCommand),
 }
 
+// A LastPassCommandConfig is a configuration for the lastpass command.
 type LastPassCommandConfig struct {
 	Lpass string
 }
diff --git a/cmd/remove.go b/cmd/remove.go
index ff0503f31..7e088b2b3 100644
--- a/cmd/remove.go
+++ b/cmd/remove.go
@@ -1,11 +1,15 @@
 package cmd
 
+// FIXME add --force flag
+// FIXME add --recursive flag
+// FIXME add --prompt flag
+
 import (
 	"os"
 	"path/filepath"
 
 	"github.com/spf13/cobra"
-	"github.com/twpayne/go-vfs"
+	vfs "github.com/twpayne/go-vfs"
 )
 
 var removeCommand = &cobra.Command{
@@ -25,16 +29,16 @@ func (c *Config) runRemoveCommand(fs vfs.FS, command *cobra.Command, args []stri
 	if err != nil {
 		return err
 	}
-	sourceNames, err := c.getSourceNames(targetState, args)
+	entries, err := c.getEntries(targetState, args)
 	if err != nil {
-		return err
+		return nil
 	}
 	actuator := c.getDefaultActuator(fs)
-	for i, targetFileName := range args {
-		if err := actuator.RemoveAll(filepath.Join(c.TargetDir, targetFileName)); err != nil && !os.IsNotExist(err) {
+	for _, entry := range entries {
+		if err := actuator.RemoveAll(filepath.Join(c.TargetDir, entry.TargetName())); err != nil && !os.IsNotExist(err) {
 			return err
 		}
-		if err := actuator.RemoveAll(filepath.Join(c.SourceDir, sourceNames[i])); err != nil && !os.IsNotExist(err) {
+		if err := actuator.RemoveAll(filepath.Join(c.SourceDir, entry.SourceName())); err != nil && !os.IsNotExist(err) {
 			return err
 		}
 	}
diff --git a/cmd/root.go b/cmd/root.go
index 6980a4eb6..1606cd125 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -5,10 +5,10 @@ import (
 	"os"
 	"path/filepath"
 
-	"github.com/mitchellh/go-homedir"
+	homedir "github.com/mitchellh/go-homedir"
 	"github.com/spf13/cobra"
 	"github.com/spf13/viper"
-	"github.com/twpayne/go-vfs"
+	vfs "github.com/twpayne/go-vfs"
 )
 
 var (
diff --git a/cmd/source.go b/cmd/source.go
index 6267db344..3cfe580f1 100644
--- a/cmd/source.go
+++ b/cmd/source.go
@@ -5,7 +5,7 @@ import (
 	"os"
 
 	"github.com/spf13/cobra"
-	"github.com/twpayne/go-vfs"
+	vfs "github.com/twpayne/go-vfs"
 )
 
 var sourceCommand = &cobra.Command{
diff --git a/cmd/verify.go b/cmd/verify.go
index b72fdc578..828e8a8df 100644
--- a/cmd/verify.go
+++ b/cmd/verify.go
@@ -5,7 +5,7 @@ import (
 
 	"github.com/spf13/cobra"
 	"github.com/twpayne/chezmoi/lib/chezmoi"
-	"github.com/twpayne/go-vfs"
+	vfs "github.com/twpayne/go-vfs"
 )
 
 var verifyCommand = &cobra.Command{
@@ -19,7 +19,7 @@ func init() {
 }
 
 func (c *Config) runVerifyCommand(fs vfs.FS, command *cobra.Command, args []string) error {
-	actuator := chezmoi.NewAnyActuator(chezmoi.NewNullActuator())
+	actuator := chezmoi.NewAnyActuator(chezmoi.NullActuator)
 	if err := c.applyArgs(fs, args, actuator); err != nil {
 		return err
 	}
diff --git a/cmd/version.go b/cmd/version.go
index e01e777f6..16a070a22 100644
--- a/cmd/version.go
+++ b/cmd/version.go
@@ -4,7 +4,7 @@ import (
 	"fmt"
 
 	"github.com/spf13/cobra"
-	"github.com/twpayne/go-vfs"
+	vfs "github.com/twpayne/go-vfs"
 )
 
 var versionCommand = &cobra.Command{
diff --git a/lib/chezmoi/chezmoi.go b/lib/chezmoi/chezmoi.go
index 78ffc9071..1607264c6 100644
--- a/lib/chezmoi/chezmoi.go
+++ b/lib/chezmoi/chezmoi.go
@@ -1,7 +1,5 @@
 package chezmoi
 
-// FIXME add Symlink
-
 import (
 	"archive/tar"
 	"bytes"
@@ -15,7 +13,7 @@ import (
 	"text/template"
 	"time"
 
-	"github.com/twpayne/go-vfs"
+	vfs "github.com/twpayne/go-vfs"
 )
 
 const (
@@ -30,22 +28,22 @@ const (
 // A templateFuncError is an error encountered while executing a template
 // function.
 type templateFuncError struct {
-	name string
-	err  error
+	err error
 }
 
 // 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
+	Evaluate() error
 	SourceName() string
-	addAllEntries(map[string]Entry, string)
-	apply(vfs.FS, string, os.FileMode, Actuator) error
-	archive(*tar.Writer, string, *tar.Header, os.FileMode) error
-	evaluate() error
+	TargetName() string
+	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
@@ -57,17 +55,19 @@ type File struct {
 // A Dir represents the target state of a directory.
 type Dir struct {
 	sourceName string
+	targetName string
 	Perm       os.FileMode
 	Entries    map[string]Entry
 }
 
 // A Symlink represents the target state of a symlink.
 type Symlink struct {
-	sourceName     string
-	Template       bool
-	target         string
-	targetErr      error
-	evaluateTarget func() (string, error)
+	sourceName       string
+	targetName       string
+	Template         bool
+	linkName         string
+	linkNameErr      error
+	evaluateLinkName func() (string, error)
 }
 
 // A TargetState represents the root target state.
@@ -100,73 +100,67 @@ type parsedSourceFilePath struct {
 }
 
 // newDir returns a new directory state.
-func newDir(sourceName string, perm os.FileMode) *Dir {
+func newDir(sourceName string, targetName string, perm os.FileMode) *Dir {
 	return &Dir{
 		sourceName: sourceName,
+		targetName: targetName,
 		Perm:       perm,
 		Entries:    make(map[string]Entry),
 	}
 }
 
-// addAllEntries adds d and all of the entries in d to result.
-func (d *Dir) addAllEntries(result map[string]Entry, name string) {
-	result[name] = d
-	for entryName, entry := range d.Entries {
-		entry.addAllEntries(result, filepath.Join(name, entryName))
-	}
-}
-
 // archive writes d to w.
-func (d *Dir) archive(w *tar.Writer, dirName string, headerTemplate *tar.Header, umask os.FileMode) error {
+func (d *Dir) archive(w *tar.Writer, headerTemplate *tar.Header, umask os.FileMode) error {
 	header := *headerTemplate
 	header.Typeflag = tar.TypeDir
-	header.Name = dirName
+	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, filepath.Join(dirName, entryName), headerTemplate, umask); err != nil {
+		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 {
-	info, err := fs.Lstat(targetDir)
+// 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(targetDir, d.Perm&^umask); err != nil {
+			if err := actuator.Chmod(targetPath, d.Perm&^umask); err != nil {
 				return err
 			}
 		}
 	case err == nil:
-		if err := actuator.RemoveAll(targetDir); err != nil {
+		if err := actuator.RemoveAll(targetPath); err != nil {
 			return err
 		}
 		fallthrough
 	case os.IsNotExist(err):
-		if err := actuator.Mkdir(targetDir, d.Perm&^umask); err != nil {
+		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, filepath.Join(targetDir, entryName), umask, actuator); err != nil {
+		if err := d.Entries[entryName].Apply(fs, targetDir, umask, actuator); err != nil {
 			return err
 		}
 	}
 	return nil
 }
 
-// evaluate evaluates all entries in d.
-func (d *Dir) evaluate() error {
+// 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 {
+		if err := d.Entries[entryName].Evaluate(); err != nil {
 			return err
 		}
 	}
@@ -183,13 +177,13 @@ func (d *Dir) SourceName() string {
 	return d.sourceName
 }
 
-// addAllEntries adds f to result.
-func (f *File) addAllEntries(result map[string]Entry, name string) {
-	result[name] = f
+// TargetName implements Entry.TargetName.
+func (d *Dir) TargetName() string {
+	return d.targetName
 }
 
 // archive writes f to w.
-func (f *File) archive(w *tar.Writer, fileName string, headerTemplate *tar.Header, umask os.FileMode) error {
+func (f *File) archive(w *tar.Writer, headerTemplate *tar.Header, umask os.FileMode) error {
 	contents, err := f.Contents()
 	if err != nil {
 		return err
@@ -199,7 +193,7 @@ func (f *File) archive(w *tar.Writer, fileName string, headerTemplate *tar.Heade
 	}
 	header := *headerTemplate
 	header.Typeflag = tar.TypeReg
-	header.Name = fileName
+	header.Name = f.targetName
 	header.Size = int64(len(contents))
 	header.Mode = int64(f.Perm &^ umask)
 	if err := w.WriteHeader(&header); err != nil {
@@ -209,12 +203,13 @@ func (f *File) archive(w *tar.Writer, fileName string, headerTemplate *tar.Heade
 	return err
 }
 
-// apply ensures that the state of targetPath in fs matches f.
-func (f *File) apply(fs vfs.FS, targetPath string, umask os.FileMode, actuator Actuator) error {
+// 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 {
@@ -249,8 +244,8 @@ func (f *File) apply(fs vfs.FS, targetPath string, umask os.FileMode, actuator A
 	return actuator.WriteFile(targetPath, contents, f.Perm&^umask, currData)
 }
 
-// evaluate evaluates f's contents.
-func (f *File) evaluate() error {
+// Evaluate evaluates f's contents.
+func (f *File) Evaluate() error {
 	_, err := f.Contents()
 	return err
 }
@@ -279,29 +274,31 @@ func (f *File) SourceName() string {
 	return f.sourceName
 }
 
-// addAllEntries adds s to result.
-func (s *Symlink) addAllEntries(result map[string]Entry, name string) {
-	result[name] = s
+// TargetName implements Entry.TargetName.
+func (f *File) TargetName() string {
+	return f.targetName
 }
 
 // archive writes s to w.
-func (s *Symlink) archive(w *tar.Writer, dirName string, headerTemplate *tar.Header, umask os.FileMode) error {
-	target, err := s.Target()
+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 = target
+	header.Linkname = linkName
 	return w.WriteHeader(&header)
 }
 
-// apply ensures that the state of targetPath in fs matches s.
-func (s *Symlink) apply(fs vfs.FS, targetPath string, umask os.FileMode, actuator Actuator) error {
-	target, err := s.Target()
+// 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:
@@ -320,23 +317,29 @@ func (s *Symlink) apply(fs vfs.FS, targetPath string, umask os.FileMode, actuato
 	return actuator.WriteSymlink(target, targetPath)
 }
 
-// evaluate evaluates s's target.
-func (s *Symlink) evaluate() error {
-	_, err := s.Target()
+// 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
 }
 
-// Target returns f's contents.
-func (s *Symlink) Target() (string, error) {
-	if s.evaluateTarget != nil {
-		s.target, s.targetErr = s.evaluateTarget()
-		s.evaluateTarget = nil
+// 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.target, s.targetErr
+	return s.linkName, s.linkNameErr
+}
+
+// TargetName implements Entry.TargetName.
+func (s *Symlink) TargetName() string {
+	return s.targetName
 }
 
 // NewTargetState creates a new TargetState.
@@ -352,17 +355,17 @@ func NewTargetState(targetDir string, umask os.FileMode, sourceDir string, data
 }
 
 // Add adds a new target to ts.
-func (ts *TargetState) Add(fs vfs.FS, target string, info os.FileInfo, addEmpty, addTemplate bool, actuator Actuator) error {
-	if !filepath.HasPrefix(target, ts.TargetDir) {
-		return fmt.Errorf("%s: outside target directory", target)
+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, target)
+	targetName, err := filepath.Rel(ts.TargetDir, targetPath)
 	if err != nil {
 		return err
 	}
 	if info == nil {
 		var err error
-		info, err = fs.Lstat(target)
+		info, err = fs.Lstat(targetPath)
 		if err != nil {
 			return err
 		}
@@ -413,7 +416,7 @@ func (ts *TargetState) Add(fs vfs.FS, target string, info os.FileInfo, addEmpty,
 		if dirSourceName != "" {
 			sourceName = filepath.Join(dirSourceName, sourceName)
 		}
-		data, err := fs.ReadFile(target)
+		data, err := fs.ReadFile(targetPath)
 		if err != nil {
 			return err
 		}
@@ -425,6 +428,7 @@ func (ts *TargetState) Add(fs vfs.FS, target string, info os.FileInfo, addEmpty,
 		}
 		entries[name] = &File{
 			sourceName: sourceName,
+			targetName: targetName,
 			Empty:      len(data) == 0,
 			Perm:       info.Mode() & os.ModePerm,
 			Template:   addTemplate,
@@ -450,7 +454,7 @@ func (ts *TargetState) Add(fs vfs.FS, target string, info os.FileInfo, addEmpty,
 		// 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(target)
+		infos, err := fs.ReadDir(targetPath)
 		if err != nil {
 			return err
 		}
@@ -459,7 +463,7 @@ func (ts *TargetState) Add(fs vfs.FS, target string, info os.FileInfo, addEmpty,
 				return err
 			}
 		}
-		entries[name] = newDir(sourceName, info.Mode()&os.ModePerm)
+		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 {
@@ -474,7 +478,7 @@ func (ts *TargetState) Add(fs vfs.FS, target string, info os.FileInfo, addEmpty,
 		if dirSourceName != "" {
 			sourceName = filepath.Join(dirSourceName, sourceName)
 		}
-		data, err := fs.Readlink(target)
+		data, err := fs.Readlink(targetPath)
 		if err != nil {
 			return err
 		}
@@ -483,7 +487,8 @@ func (ts *TargetState) Add(fs vfs.FS, target string, info os.FileInfo, addEmpty,
 		}
 		entries[name] = &Symlink{
 			sourceName: sourceName,
-			target:     data,
+			targetName: targetName,
+			linkName:   data,
 		}
 	default:
 		return fmt.Errorf("%s: not a regular file or directory", targetName)
@@ -491,15 +496,6 @@ func (ts *TargetState) Add(fs vfs.FS, target string, info os.FileInfo, addEmpty,
 	return nil
 }
 
-// AllEntries returns all the Entries in ts.
-func (ts *TargetState) AllEntries() map[string]Entry {
-	result := make(map[string]Entry)
-	for entryName, entry := range ts.Entries {
-		entry.addAllEntries(result, entryName)
-	}
-	return result
-}
-
 // Archive writes ts to w.
 func (ts *TargetState) Archive(w *tar.Writer, umask os.FileMode) error {
 	currentUser, err := user.Current()
@@ -529,7 +525,7 @@ func (ts *TargetState) Archive(w *tar.Writer, umask os.FileMode) error {
 		ChangeTime: now,
 	}
 	for _, entryName := range sortedEntryNames(ts.Entries) {
-		if err := ts.Entries[entryName].archive(w, entryName, &headerTemplate, umask); err != nil {
+		if err := ts.Entries[entryName].archive(w, &headerTemplate, umask); err != nil {
 			return err
 		}
 	}
@@ -539,25 +535,17 @@ func (ts *TargetState) Archive(w *tar.Writer, umask os.FileMode) error {
 // 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, filepath.Join(ts.TargetDir, entryName), ts.Umask, actuator); err != nil {
+		if err := ts.Entries[entryName].Apply(fs, ts.TargetDir, ts.Umask, actuator); err != nil {
 			return err
 		}
 	}
 	return nil
 }
 
-// ApplyOne ensures that targetPath matches entry.
-func (ts *TargetState) ApplyOne(fs vfs.FS, targetPath string, entry Entry, actuator Actuator) error {
-	if !filepath.HasPrefix(targetPath, ts.TargetDir) {
-		return fmt.Errorf("%s: outside target directory", targetPath)
-	}
-	return entry.apply(fs, targetPath, ts.Umask, actuator)
-}
-
-// Evaluates all of the entries in ts.
+// 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 {
+		if err := ts.Entries[entryName].Evaluate(); err != nil {
 			return err
 		}
 	}
@@ -578,7 +566,7 @@ func (ts *TargetState) Get(target string) (Entry, error) {
 
 // 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, err error) 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
@@ -600,6 +588,8 @@ func (ts *TargetState) Populate(fs vfs.FS) error {
 			if err != nil {
 				return err
 			}
+
+			targetName := filepath.Join(append(psfp.dirNames, psfp.FileName)...)
 			var entry Entry
 			switch psfp.Mode & os.ModeType {
 			case 0:
@@ -613,26 +603,28 @@ func (ts *TargetState) Populate(fs vfs.FS) error {
 				}
 				entry = &File{
 					sourceName:       relPath,
+					targetName:       targetName,
 					Empty:            psfp.Empty,
 					Perm:             psfp.Mode & os.ModePerm,
 					Template:         psfp.Template,
 					evaluateContents: evaluateContents,
 				}
 			case os.ModeSymlink:
-				evaluateTarget := func() (string, error) {
+				evaluateLinkName := func() (string, error) {
 					data, err := fs.ReadFile(path)
 					return string(data), err
 				}
 				if psfp.Template {
-					evaluateTarget = func() (string, error) {
+					evaluateLinkName = func() (string, error) {
 						data, err := ts.executeTemplate(fs, path)
 						return string(data), err
 					}
 				}
 				entry = &Symlink{
-					sourceName:     relPath,
-					Template:       psfp.Template,
-					evaluateTarget: evaluateTarget,
+					sourceName:       relPath,
+					targetName:       targetName,
+					Template:         psfp.Template,
+					evaluateLinkName: evaluateLinkName,
 				}
 			default:
 				return fmt.Errorf("%v: unsupported mode: %d", path, psfp.Mode&os.ModeType)
@@ -641,13 +633,14 @@ func (ts *TargetState) Populate(fs vfs.FS) error {
 		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, perm)
+			entries[dirName] = newDir(relPath, targetName, perm)
 		default:
 			return fmt.Errorf("unsupported file type: %s", path)
 		}
diff --git a/lib/chezmoi/fs_actuator.go b/lib/chezmoi/fs_actuator.go
index 10eeb08be..e428a0e8f 100644
--- a/lib/chezmoi/fs_actuator.go
+++ b/lib/chezmoi/fs_actuator.go
@@ -4,7 +4,7 @@ import (
 	"os"
 
 	"github.com/google/renameio"
-	"github.com/twpayne/go-vfs"
+	vfs "github.com/twpayne/go-vfs"
 )
 
 // An FSActuator makes changes to an vfs.FS.
@@ -34,7 +34,9 @@ func (a *FSActuator) WriteFile(name string, data []byte, perm os.FileMode, currD
 		if err != nil {
 			return err
 		}
-		defer t.Cleanup()
+		defer func() {
+			_ = t.Cleanup()
+		}()
 		if err := t.Chmod(perm); err != nil {
 			return err
 		}
@@ -46,7 +48,7 @@ func (a *FSActuator) WriteFile(name string, data []byte, perm os.FileMode, currD
 	return a.FS.WriteFile(name, data, perm)
 }
 
-// Symlink implements Actuator.WriteSymlink.
+// WriteSymlink implements Actuator.WriteSymlink.
 func (a *FSActuator) WriteSymlink(oldname, newname string) error {
 	// Special case: if writing to the real filesystem, use github.com/google/renameio
 	if a.FS == vfs.OSFS {
diff --git a/lib/chezmoi/logging_actuator.go b/lib/chezmoi/logging_actuator.go
index f0c9951ee..47f53b320 100644
--- a/lib/chezmoi/logging_actuator.go
+++ b/lib/chezmoi/logging_actuator.go
@@ -27,9 +27,9 @@ 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)
+		_, _ = fmt.Fprintln(a.w, action)
 	} else {
-		fmt.Fprintf(a.w, "%s: %v\n", action, err)
+		_, _ = fmt.Fprintf(a.w, "%s: %v\n", action, err)
 	}
 	return err
 }
@@ -39,9 +39,9 @@ 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)
+		_, _ = fmt.Fprintln(a.w, action)
 	} else {
-		fmt.Fprintf(a.w, "%s: %v\n", action, err)
+		_, _ = fmt.Fprintf(a.w, "%s: %v\n", action, err)
 	}
 	return err
 }
@@ -51,9 +51,9 @@ 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)
+		_, _ = fmt.Fprintln(a.w, action)
 	} else {
-		fmt.Fprintf(a.w, "%s: %v\n", action, err)
+		_, _ = fmt.Fprintf(a.w, "%s: %v\n", action, err)
 	}
 	return err
 }
@@ -63,9 +63,9 @@ 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)
+		_, _ = fmt.Fprintln(a.w, action)
 	} else {
-		fmt.Fprintf(a.w, "%s: %v\n", action, err)
+		_, _ = fmt.Fprintf(a.w, "%s: %v\n", action, err)
 	}
 	return err
 }
@@ -75,14 +75,14 @@ func (a *LoggingActuator) WriteFile(name string, data []byte, perm os.FileMode,
 	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)
+		_, _ = 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)
+				_, _ = fmt.Fprintf(a.w, "%c%s\n", section.ctype, s)
 			}
 		}
 	} else {
-		fmt.Fprintf(a.w, "%s: %v\n", action, err)
+		_, _ = fmt.Fprintf(a.w, "%s: %v\n", action, err)
 	}
 	return err
 }
@@ -92,9 +92,9 @@ 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)
+		_, _ = fmt.Fprintln(a.w, action)
 	} else {
-		fmt.Fprintf(a.w, "%s: %v\n", action, err)
+		_, _ = fmt.Fprintf(a.w, "%s: %v\n", action, err)
 	}
 	return err
 }
diff --git a/lib/chezmoi/null_actuator.go b/lib/chezmoi/null_actuator.go
index 55c186812..7dee5308f 100644
--- a/lib/chezmoi/null_actuator.go
+++ b/lib/chezmoi/null_actuator.go
@@ -2,40 +2,37 @@ package chezmoi
 
 import "os"
 
-// A NullActuator does nothing.
-type NullActuator struct{}
+type nullActuator struct{}
 
-// NewNullActuator returns a new NullActuator.
-func NewNullActuator() *NullActuator {
-	return &NullActuator{}
-}
+// NullActuator is an Actuator that does nothing.
+var NullActuator nullActuator
 
 // Chmod implements Actuator.Chmod.
-func (a *NullActuator) Chmod(string, os.FileMode) error {
+func (nullActuator) Chmod(string, os.FileMode) error {
 	return nil
 }
 
 // Mkdir implements Actuator.Mkdir.
-func (a *NullActuator) Mkdir(string, os.FileMode) error {
+func (nullActuator) Mkdir(string, os.FileMode) error {
 	return nil
 }
 
 // RemoveAll implements Actuator.RemoveAll.
-func (a *NullActuator) RemoveAll(string) error {
+func (nullActuator) RemoveAll(string) error {
 	return nil
 }
 
 // Rename implements Actuator.Rename.
-func (a *NullActuator) Rename(string, string) error {
+func (nullActuator) Rename(string, string) error {
 	return nil
 }
 
 // WriteFile implements Actuator.WriteFile.
-func (a *NullActuator) WriteFile(string, []byte, os.FileMode, []byte) error {
+func (nullActuator) WriteFile(string, []byte, os.FileMode, []byte) error {
 	return nil
 }
 
 // WriteSymlink implements Actuator.WriteSymlink.
-func (a *NullActuator) WriteSymlink(string, string) error {
+func (nullActuator) WriteSymlink(string, string) error {
 	return nil
 }


Test patch:
diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go
new file mode 100644
index 000000000..12a5d6bf8
--- /dev/null
+++ b/cmd/cmd_test.go
@@ -0,0 +1,121 @@
+package cmd
+
+import (
+	"os"
+	"testing"
+
+	"github.com/twpayne/go-vfs/vfst"
+)
+
+// TestExercise exercises a few commands.
+func TestExercise(t *testing.T) {
+	fs, cleanup, err := vfst.NewTestFS(map[string]interface{}{
+		"/home/user/.bashrc": "# contents of .bashrc\n",
+	})
+	defer cleanup()
+	if err != nil {
+		t.Fatalf("vfst.NewTestFS() == _, _, %v, want _, _, <nil>", err)
+	}
+
+	c := &Config{
+		SourceDir: "/home/user/.chezmoi",
+		TargetDir: "/home/user",
+		Umask:     022,
+		Verbose:   true,
+	}
+
+	mustWriteFile := func(name, contents string, mode os.FileMode) {
+		if err := fs.WriteFile(name, []byte(contents), mode); err != nil {
+			t.Errorf("fs.WriteFile(%q, []byte(%q), %o) == %v, want <nil>", name, contents, mode, err)
+		}
+	}
+
+	// chezmoi add ~/.bashrc
+	t.Run("chezmoi_add_bashrc", func(t *testing.T) {
+		if err := c.runAddCommand(fs, nil, []string{"/home/user/.bashrc"}); err != nil {
+			t.Errorf("c.runAddCommand(...) == %v, want <nil>", err)
+		}
+		vfst.RunTests(t, fs, "",
+			vfst.TestPath("/home/user/.chezmoi",
+				vfst.TestIsDir,
+				vfst.TestModePerm(0700),
+			),
+			vfst.TestPath("/home/user/.chezmoi/dot_bashrc",
+				vfst.TestModeIsRegular,
+				vfst.TestModePerm(0644),
+				vfst.TestContentsString("# contents of .bashrc\n"),
+			),
+		)
+	})
+
+	// chezmoi forget ~/.bashrc
+	t.Run("chezmoi_forget_bashrc", func(t *testing.T) {
+		if err := c.runForgetCommand(fs, nil, []string{"/home/user/.bashrc"}); err != nil {
+			t.Errorf("c.runForgetCommand(...) == %v, want <nil>", err)
+		}
+		vfst.RunTests(t, fs, "",
+			vfst.TestPath("/home/user/.chezmoi/dot_bashrc",
+				vfst.TestDoesNotExist,
+			),
+		)
+	})
+
+	// chezmoi add ~/.netrc
+	t.Run("chezmoi_add_netrc", func(t *testing.T) {
+		mustWriteFile("/home/user/.netrc", "# contents of .netrc\n", 0600)
+		if err := c.runAddCommand(fs, nil, []string{"/home/user/.netrc"}); err != nil {
+			t.Errorf("c.runAddCommand(...) == %v, want <nil>", err)
+		}
+		vfst.RunTests(t, fs, "",
+			vfst.TestPath("/home/user/.chezmoi/private_dot_netrc",
+				vfst.TestModeIsRegular,
+				vfst.TestModePerm(0644),
+				vfst.TestContentsString("# contents of .netrc\n"),
+			),
+		)
+	})
+
+	// chezmoi chattr -- -private,+empty ~/.netrc
+	t.Run("chezmoi_chattr_netrc", func(t *testing.T) {
+		if err := c.runChattrCommand(fs, nil, []string{"-private,+empty", "/home/user/.netrc"}); err != nil {
+			t.Errorf("c.runChattrCommand(...) == %v, want <nil>", err)
+		}
+		vfst.RunTests(t, fs, "",
+			vfst.TestPath("/home/user/.chezmoi/empty_dot_netrc",
+				vfst.TestModeIsRegular,
+				vfst.TestModePerm(0644),
+				vfst.TestContentsString("# contents of .netrc\n"),
+			),
+		)
+	})
+
+	// chezmoi apply ~/.netrc
+	t.Run("chezmoi_apply_netrc", func(t *testing.T) {
+		if err := c.runApplyCommand(fs, nil, []string{"/home/user/.netrc"}); err != nil {
+			t.Errorf("c.runApplyCommand(...) == %v, want <nil>", err)
+		}
+		vfst.RunTests(t, fs, "",
+			vfst.TestPath("/home/user/.netrc",
+				vfst.TestModeIsRegular,
+				vfst.TestModePerm(0644),
+				vfst.TestContentsString("# contents of .netrc\n"),
+			),
+		)
+	})
+
+	// chezmoi remove ~/.netrc
+	t.Run("chezmoi_remove_netrc", func(t *testing.T) {
+		if err := c.runRemoveCommand(fs, nil, []string{"/home/user/.netrc"}); err != nil {
+			t.Errorf("c.runRemoveCommand(...) == %v, want <nil>", err)
+		}
+		vfst.RunTests(t, fs, "",
+			vfst.TestPath("/home/user/.netrc",
+				vfst.TestDoesNotExist,
+			),
+			vfst.TestPath("/home/user/.chezmoi/empty_dot_netrc",
+				vfst.TestDoesNotExist,
+			),
+		)
+	})
+
+}
diff --git a/lib/chezmoi/chezmoi_test.go b/lib/chezmoi/chezmoi_test.go
index c86d4b26e..438f6eb56 100644
--- a/lib/chezmoi/chezmoi_test.go
+++ b/lib/chezmoi/chezmoi_test.go
@@ -190,6 +190,7 @@ func TestTargetStatePopulate(t *testing.T) {
 				Entries: map[string]Entry{
 					"foo": &File{
 						sourceName: "foo",
+						targetName: "foo",
 						Perm:       0666,
 						contents:   []byte("bar"),
 					},
@@ -209,6 +210,7 @@ func TestTargetStatePopulate(t *testing.T) {
 				Entries: map[string]Entry{
 					".foo": &File{
 						sourceName: "dot_foo",
+						targetName: ".foo",
 						Perm:       0666,
 						contents:   []byte("bar"),
 					},
@@ -228,6 +230,7 @@ func TestTargetStatePopulate(t *testing.T) {
 				Entries: map[string]Entry{
 					"foo": &File{
 						sourceName: "private_foo",
+						targetName: "foo",
 						Perm:       0600,
 						contents:   []byte("bar"),
 					},
@@ -247,10 +250,12 @@ func TestTargetStatePopulate(t *testing.T) {
 				Entries: map[string]Entry{
 					"foo": &Dir{
 						sourceName: "foo",
+						targetName: "foo",
 						Perm:       0777,
 						Entries: map[string]Entry{
 							"bar": &File{
 								sourceName: "foo/bar",
+								targetName: "foo/bar",
 								Perm:       0666,
 								contents:   []byte("baz"),
 							},
@@ -272,10 +277,12 @@ func TestTargetStatePopulate(t *testing.T) {
 				Entries: map[string]Entry{
 					".foo": &Dir{
 						sourceName: "private_dot_foo",
+						targetName: ".foo",
 						Perm:       0700,
 						Entries: map[string]Entry{
 							"bar": &File{
 								sourceName: "private_dot_foo/bar",
+								targetName: ".foo/bar",
 								Perm:       0666,
 								contents:   []byte("baz"),
 							},
@@ -303,6 +310,7 @@ func TestTargetStatePopulate(t *testing.T) {
 				Entries: map[string]Entry{
 					".gitconfig": &File{
 						sourceName: "dot_gitconfig.tmpl",
+						targetName: ".gitconfig",
 						Perm:       0666,
 						Template:   true,
 						contents:   []byte("[user]\n\temail = user@example.com\n"),
@@ -323,7 +331,8 @@ func TestTargetStatePopulate(t *testing.T) {
 				Entries: map[string]Entry{
 					"foo": &Symlink{
 						sourceName: "symlink_foo",
-						target:     "bar",
+						targetName: "foo",
+						linkName:   "bar",
 					},
 				},
 			},
@@ -341,7 +350,8 @@ func TestTargetStatePopulate(t *testing.T) {
 				Entries: map[string]Entry{
 					".foo": &Symlink{
 						sourceName: "symlink_dot_foo",
-						target:     "bar",
+						targetName: ".foo",
+						linkName:   "bar",
 					},
 				},
 			},
@@ -365,8 +375,9 @@ func TestTargetStatePopulate(t *testing.T) {
 				Entries: map[string]Entry{
 					"foo": &Symlink{
 						sourceName: "symlink_foo.tmpl",
+						targetName: "foo",
 						Template:   true,
-						target:     "bar-example.com",
+						linkName:   "bar-example.com",
 					},
 				},
 			},