Repository: twpayne/chezmoi
Pull request #81 (2018-12-21 13:08:36)

Internal refactor


Patch:
diff --git a/README.md b/README.md
index 20fdb0ed7..57f30a398 100644
--- a/README.md
+++ b/README.md
@@ -254,14 +254,6 @@ the field you want. For example, to extract the `password` field from first the
 
     githubPassword = {{ (index (lastpass "Github") 0).password }}
 
-`chezmoi` automatically parses the `note` value of the Lastpass entry, so, for
-example, you can extract a private SSH key like this:
-
-    {{ (index (lastpass "SSH") 0).note.privateKey }}
-
-Keys in the `note` section written as `CamelCase Words` are converted to
-`camelCaseWords`.
-
 ### Using keyring
 
 `chezmoi` includes support for Keychain (on macOS), GNOME Keyring (on Linux),
diff --git a/cmd/chattr.go b/cmd/chattr.go
index a12002a8d..ee886875c 100644
--- a/cmd/chattr.go
+++ b/cmd/chattr.go
@@ -56,13 +56,13 @@ func (c *Config) runChattrCommand(fs vfs.FS, cmd *cobra.Command, args []string)
 		var newSourceName string
 		switch entry := entry.(type) {
 		case *chezmoi.Dir:
-			psdn := chezmoi.ParseSourceDirName(oldSourceName)
+			da := chezmoi.ParseDirAttributes(oldSourceName)
 			if private := ams.private.modify(entry.Private()); private {
-				psdn.Perm &= 0700
+				da.Perm &= 0700
 			}
-			newSourceName = psdn.SourceDirName()
+			newSourceName = da.SourceName()
 		case *chezmoi.File:
-			psfn := chezmoi.ParseSourceFileName(oldSourceName)
+			fa := chezmoi.ParseFileAttributes(oldSourceName)
 			mode := os.FileMode(0666)
 			if executable := ams.executable.modify(entry.Executable()); executable {
 				mode |= 0111
@@ -70,14 +70,14 @@ func (c *Config) runChattrCommand(fs vfs.FS, cmd *cobra.Command, args []string)
 			if private := ams.private.modify(entry.Private()); private {
 				mode &= 0700
 			}
-			psfn.Mode = mode
-			psfn.Empty = ams.empty.modify(entry.Empty)
-			psfn.Template = ams.template.modify(entry.Template)
-			newSourceName = psfn.SourceFileName()
+			fa.Mode = mode
+			fa.Empty = ams.empty.modify(entry.Empty)
+			fa.Template = ams.template.modify(entry.Template)
+			newSourceName = fa.SourceName()
 		case *chezmoi.Symlink:
-			psfn := chezmoi.ParseSourceFileName(oldSourceName)
-			psfn.Template = ams.template.modify(entry.Template)
-			newSourceName = psfn.SourceFileName()
+			fa := chezmoi.ParseFileAttributes(oldSourceName)
+			fa.Template = ams.template.modify(entry.Template)
+			newSourceName = fa.SourceName()
 		}
 		if newSourceName != oldSourceName {
 			renames[filepath.Join(ts.SourceDir, oldSourceName)] = filepath.Join(ts.SourceDir, newSourceName)
diff --git a/cmd/lastpass.go b/cmd/lastpass.go
index 60d7216dc..3782aacf6 100644
--- a/cmd/lastpass.go
+++ b/cmd/lastpass.go
@@ -1,14 +1,10 @@
 package cmd
 
 import (
-	"bufio"
-	"bytes"
 	"encoding/json"
 	"fmt"
 	"os/exec"
-	"regexp"
 	"strings"
-	"unicode"
 
 	"github.com/spf13/cobra"
 	"github.com/twpayne/chezmoi/lib/chezmoi"
@@ -21,8 +17,6 @@ var lastpassCommand = &cobra.Command{
 	RunE:  makeRunE(config.runLastPassCommand),
 }
 
-var lastpassParseNoteRegexp = regexp.MustCompile(`\A([ A-Za-z]*):(.*)\z`)
-
 // A LastPassCommandConfig is a configuration for the lastpass command.
 type LastPassCommandConfig struct {
 	Lpass string
@@ -57,33 +51,6 @@ func (c *Config) lastpassFunc(id string) interface{} {
 	if err := json.Unmarshal(output, &data); err != nil {
 		chezmoi.ReturnTemplateFuncError(fmt.Errorf("lastpass %q: %s show -j %q: %v", id, name, id, err))
 	}
-	for _, d := range data {
-		if note, ok := d["note"].(string); ok {
-			d["note"] = lastpassParseNote(note)
-		}
-	}
 	lastPassCache[id] = data
 	return data
 }
-
-func lastpassParseNote(note string) map[string]string {
-	result := make(map[string]string)
-	s := bufio.NewScanner(bytes.NewBufferString(note))
-	key := ""
-	for s.Scan() {
-		if m := lastpassParseNoteRegexp.FindStringSubmatch(s.Text()); m != nil {
-			keyComponents := strings.Split(m[1], " ")
-			firstComponentRunes := []rune(keyComponents[0])
-			firstComponentRunes[0] = unicode.ToLower(firstComponentRunes[0])
-			keyComponents[0] = string(firstComponentRunes)
-			key = strings.Join(keyComponents, "")
-			result[key] = m[2] + "\n"
-		} else {
-			result[key] += s.Text() + "\n"
-		}
-	}
-	if err := s.Err(); err != nil {
-		chezmoi.ReturnTemplateFuncError(fmt.Errorf("lastpassParseNote: %v", err))
-	}
-	return result
-}
diff --git a/lib/chezmoi/chezmoi.go b/lib/chezmoi/chezmoi.go
index 704cd6bb9..8fb7029c5 100644
--- a/lib/chezmoi/chezmoi.go
+++ b/lib/chezmoi/chezmoi.go
@@ -2,7 +2,6 @@ package chezmoi
 
 import (
 	"archive/tar"
-	"fmt"
 	"os"
 	"path/filepath"
 	"sort"
@@ -36,22 +35,8 @@ type Entry interface {
 	archive(w *tar.Writer, headerTemplate *tar.Header, umask os.FileMode) error
 }
 
-// ParsedSourceDirName is a parsed source dir name.
-type ParsedSourceDirName struct {
-	DirName string
-	Perm    os.FileMode
-}
-
-// A ParsedSourceFileName is a parsed source file name.
-type ParsedSourceFileName struct {
-	FileName string
-	Mode     os.FileMode
-	Empty    bool
-	Template bool
-}
-
 type parsedSourceFilePath struct {
-	ParsedSourceFileName
+	FileAttributes
 	dirNames []string
 }
 
@@ -62,116 +47,15 @@ func ReturnTemplateFuncError(err error) {
 	})
 }
 
-// ParseSourceDirName parses a single directory name.
-func ParseSourceDirName(dirName string) ParsedSourceDirName {
-	perm := os.FileMode(0777)
-	if strings.HasPrefix(dirName, privatePrefix) {
-		dirName = strings.TrimPrefix(dirName, privatePrefix)
-		perm &= 0700
-	}
-	if strings.HasPrefix(dirName, dotPrefix) {
-		dirName = "." + strings.TrimPrefix(dirName, dotPrefix)
-	}
-	return ParsedSourceDirName{
-		DirName: dirName,
-		Perm:    perm,
-	}
-}
-
-// SourceDirName returns psdn's source dir name.
-func (psdn ParsedSourceDirName) SourceDirName() string {
-	sourceDirName := ""
-	if psdn.Perm&os.FileMode(077) == os.FileMode(0) {
-		sourceDirName = privatePrefix
-	}
-	if strings.HasPrefix(psdn.DirName, ".") {
-		sourceDirName += dotPrefix + strings.TrimPrefix(psdn.DirName, ".")
-	} else {
-		sourceDirName += psdn.DirName
-	}
-	return sourceDirName
-}
-
-// ParseSourceFileName parses a source file name.
-func ParseSourceFileName(fileName string) ParsedSourceFileName {
-	mode := os.FileMode(0666)
-	empty := false
-	template := false
-	if strings.HasPrefix(fileName, symlinkPrefix) {
-		fileName = strings.TrimPrefix(fileName, symlinkPrefix)
-		mode |= os.ModeSymlink
-	} else {
-		private := false
-		if strings.HasPrefix(fileName, privatePrefix) {
-			fileName = strings.TrimPrefix(fileName, privatePrefix)
-			private = true
-		}
-		if strings.HasPrefix(fileName, emptyPrefix) {
-			fileName = strings.TrimPrefix(fileName, emptyPrefix)
-			empty = true
-		}
-		if strings.HasPrefix(fileName, executablePrefix) {
-			fileName = strings.TrimPrefix(fileName, executablePrefix)
-			mode |= 0111
-		}
-		if private {
-			mode &= 0700
-		}
-	}
-	if strings.HasPrefix(fileName, dotPrefix) {
-		fileName = "." + strings.TrimPrefix(fileName, dotPrefix)
-	}
-	if strings.HasSuffix(fileName, templateSuffix) {
-		fileName = strings.TrimSuffix(fileName, templateSuffix)
-		template = true
-	}
-	return ParsedSourceFileName{
-		FileName: fileName,
-		Mode:     mode,
-		Empty:    empty,
-		Template: template,
-	}
-}
-
-// SourceFileName returns psfn's source file name.
-func (psfn ParsedSourceFileName) SourceFileName() string {
-	fileName := ""
-	switch psfn.Mode & os.ModeType {
-	case 0:
-		if psfn.Mode.Perm()&os.FileMode(077) == os.FileMode(0) {
-			fileName = privatePrefix
-		}
-		if psfn.Empty {
-			fileName += emptyPrefix
-		}
-		if psfn.Mode.Perm()&os.FileMode(0111) != os.FileMode(0) {
-			fileName += executablePrefix
-		}
-	case os.ModeSymlink:
-		fileName = symlinkPrefix
-	default:
-		panic(fmt.Sprintf("%+v: unsupported type", psfn)) // FIXME return error instead of panicing
-	}
-	if strings.HasPrefix(psfn.FileName, ".") {
-		fileName += dotPrefix + strings.TrimPrefix(psfn.FileName, ".")
-	} else {
-		fileName += psfn.FileName
-	}
-	if psfn.Template {
-		fileName += templateSuffix
-	}
-	return fileName
-}
-
 // parseDirNameComponents parses multiple directory name components. It returns
 // the target directory names, target permissions, and any error.
 func parseDirNameComponents(components []string) ([]string, []os.FileMode) {
 	dirNames := []string{}
 	perms := []os.FileMode{}
 	for _, component := range components {
-		psdn := ParseSourceDirName(component)
-		dirNames = append(dirNames, psdn.DirName)
-		perms = append(perms, psdn.Perm)
+		da := ParseDirAttributes(component)
+		dirNames = append(dirNames, da.Name)
+		perms = append(perms, da.Perm)
 	}
 	return dirNames, perms
 }
@@ -180,10 +64,10 @@ func parseDirNameComponents(components []string) ([]string, []os.FileMode) {
 func parseSourceFilePath(path string) parsedSourceFilePath {
 	components := splitPathList(path)
 	dirNames, _ := parseDirNameComponents(components[0 : len(components)-1])
-	psfn := ParseSourceFileName(components[len(components)-1])
+	fa := ParseFileAttributes(components[len(components)-1])
 	return parsedSourceFilePath{
-		ParsedSourceFileName: psfn,
-		dirNames:             dirNames,
+		FileAttributes: fa,
+		dirNames:       dirNames,
 	}
 }
 
diff --git a/lib/chezmoi/dir.go b/lib/chezmoi/dir.go
index ba75fb9d9..7fc77838f 100644
--- a/lib/chezmoi/dir.go
+++ b/lib/chezmoi/dir.go
@@ -4,10 +4,17 @@ import (
 	"archive/tar"
 	"os"
 	"path/filepath"
+	"strings"
 
 	vfs "github.com/twpayne/go-vfs"
 )
 
+// DirAttributes holds attributes parsed from a source directory name.
+type DirAttributes struct {
+	Name string
+	Perm os.FileMode
+}
+
 // A Dir represents the target state of a directory.
 type Dir struct {
 	sourceName string
@@ -24,6 +31,37 @@ type dirConcreteValue struct {
 	Entries    []interface{} `json:"entries" yaml:"entries"`
 }
 
+// ParseDirAttributes parses a single directory name.
+func ParseDirAttributes(sourceName string) DirAttributes {
+	name := sourceName
+	perm := os.FileMode(0777)
+	if strings.HasPrefix(name, privatePrefix) {
+		name = strings.TrimPrefix(name, privatePrefix)
+		perm &= 0700
+	}
+	if strings.HasPrefix(name, dotPrefix) {
+		name = "." + strings.TrimPrefix(name, dotPrefix)
+	}
+	return DirAttributes{
+		Name: name,
+		Perm: perm,
+	}
+}
+
+// SourceName returns da's source name.
+func (da DirAttributes) SourceName() string {
+	sourceName := ""
+	if da.Perm&os.FileMode(077) == os.FileMode(0) {
+		sourceName = privatePrefix
+	}
+	if strings.HasPrefix(da.Name, ".") {
+		sourceName += dotPrefix + strings.TrimPrefix(da.Name, ".")
+	} else {
+		sourceName += da.Name
+	}
+	return sourceName
+}
+
 // newDir returns a new directory state.
 func newDir(sourceName string, targetName string, perm os.FileMode) *Dir {
 	return &Dir{
diff --git a/lib/chezmoi/file.go b/lib/chezmoi/file.go
index bd0375bcb..80d66bf48 100644
--- a/lib/chezmoi/file.go
+++ b/lib/chezmoi/file.go
@@ -3,12 +3,22 @@ package chezmoi
 import (
 	"archive/tar"
 	"bytes"
+	"fmt"
 	"os"
 	"path/filepath"
+	"strings"
 
 	vfs "github.com/twpayne/go-vfs"
 )
 
+// A FileAttributes holds attributes passed from a source file name.
+type FileAttributes struct {
+	Name     string
+	Mode     os.FileMode
+	Empty    bool
+	Template bool
+}
+
 // A File represents the target state of a file.
 type File struct {
 	sourceName       string
@@ -31,6 +41,78 @@ type fileConcreteValue struct {
 	Contents   string `json:"contents" yaml:"contents"`
 }
 
+// ParseFileAttributes parses a source file name.
+func ParseFileAttributes(sourceName string) FileAttributes {
+	name := sourceName
+	mode := os.FileMode(0666)
+	empty := false
+	template := false
+	if strings.HasPrefix(name, symlinkPrefix) {
+		name = strings.TrimPrefix(name, symlinkPrefix)
+		mode |= os.ModeSymlink
+	} else {
+		private := false
+		if strings.HasPrefix(name, privatePrefix) {
+			name = strings.TrimPrefix(name, privatePrefix)
+			private = true
+		}
+		if strings.HasPrefix(name, emptyPrefix) {
+			name = strings.TrimPrefix(name, emptyPrefix)
+			empty = true
+		}
+		if strings.HasPrefix(name, executablePrefix) {
+			name = strings.TrimPrefix(name, executablePrefix)
+			mode |= 0111
+		}
+		if private {
+			mode &= 0700
+		}
+	}
+	if strings.HasPrefix(name, dotPrefix) {
+		name = "." + strings.TrimPrefix(name, dotPrefix)
+	}
+	if strings.HasSuffix(name, templateSuffix) {
+		name = strings.TrimSuffix(name, templateSuffix)
+		template = true
+	}
+	return FileAttributes{
+		Name:     name,
+		Mode:     mode,
+		Empty:    empty,
+		Template: template,
+	}
+}
+
+// SourceName returns fa's source name.
+func (fa FileAttributes) SourceName() string {
+	sourceName := ""
+	switch fa.Mode & os.ModeType {
+	case 0:
+		if fa.Mode.Perm()&os.FileMode(077) == os.FileMode(0) {
+			sourceName = privatePrefix
+		}
+		if fa.Empty {
+			sourceName += emptyPrefix
+		}
+		if fa.Mode.Perm()&os.FileMode(0111) != os.FileMode(0) {
+			sourceName += executablePrefix
+		}
+	case os.ModeSymlink:
+		sourceName = symlinkPrefix
+	default:
+		panic(fmt.Sprintf("%+v: unsupported type", fa)) // FIXME return error instead of panicing
+	}
+	if strings.HasPrefix(fa.Name, ".") {
+		sourceName += dotPrefix + strings.TrimPrefix(fa.Name, ".")
+	} else {
+		sourceName += fa.Name
+	}
+	if fa.Template {
+		sourceName += templateSuffix
+	}
+	return sourceName
+}
+
 // 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()
diff --git a/lib/chezmoi/target_state.go b/lib/chezmoi/target_state.go
index fcd1886e4..765bb9e87 100644
--- a/lib/chezmoi/target_state.go
+++ b/lib/chezmoi/target_state.go
@@ -251,7 +251,7 @@ func (ts *TargetState) Populate(fs vfs.FS) error {
 				return err
 			}
 
-			targetName := filepath.Join(append(psfp.dirNames, psfp.FileName)...)
+			targetName := filepath.Join(append(psfp.dirNames, psfp.Name)...)
 			var entry Entry
 			switch psfp.Mode & os.ModeType {
 			case 0:
@@ -291,7 +291,7 @@ func (ts *TargetState) Populate(fs vfs.FS) error {
 			default:
 				return fmt.Errorf("%v: unsupported mode: %d", path, psfp.Mode&os.ModeType)
 			}
-			entries[psfp.FileName] = entry
+			entries[psfp.Name] = entry
 		default:
 			return fmt.Errorf("unsupported file type: %s", path)
 		}
@@ -308,10 +308,10 @@ func (ts *TargetState) addDir(targetName string, entries map[string]Entry, paren
 			return fmt.Errorf("%s: already added and not a directory", targetName)
 		}
 	}
-	sourceName := ParsedSourceDirName{
-		DirName: name,
-		Perm:    perm,
-	}.SourceDirName()
+	sourceName := DirAttributes{
+		Name: name,
+		Perm: perm,
+	}.SourceName()
 	if parentDirSourceName != "" {
 		sourceName = filepath.Join(parentDirSourceName, sourceName)
 	}
@@ -351,12 +351,12 @@ func (ts *TargetState) addFile(targetName string, entries map[string]Entry, pare
 	}
 	perm := info.Mode().Perm()
 	empty := info.Size() == 0
-	sourceName := ParsedSourceFileName{
-		FileName: name,
+	sourceName := FileAttributes{
+		Name:     name,
 		Mode:     perm,
 		Empty:    empty,
 		Template: template,
-	}.SourceFileName()
+	}.SourceName()
 	if parentDirSourceName != "" {
 		sourceName = filepath.Join(parentDirSourceName, sourceName)
 	}
@@ -398,10 +398,10 @@ func (ts *TargetState) addSymlink(targetName string, entries map[string]Entry, p
 			return err
 		}
 	}
-	sourceName := ParsedSourceFileName{
-		FileName: name,
-		Mode:     os.ModeSymlink,
-	}.SourceFileName()
+	sourceName := FileAttributes{
+		Name: name,
+		Mode: os.ModeSymlink,
+	}.SourceName()
 	if parentDirSourceName != "" {
 		sourceName = filepath.Join(parentDirSourceName, sourceName)
 	}


Test patch:
diff --git a/cmd/lastpass_test.go b/cmd/lastpass_test.go
deleted file mode 100644
index c7b4cae24..000000000
--- a/cmd/lastpass_test.go
+++ /dev/null
@@ -1,47 +0,0 @@
-package cmd
-
-import (
-	"testing"
-
-	"github.com/d4l3k/messagediff"
-)
-
-func Test_lastpassParseNote(t *testing.T) {
-	for _, tc := range []struct {
-		note string
-		want map[string]string
-	}{
-		{
-			note: "Foo:bar\n",
-			want: map[string]string{
-				"foo": "bar\n",
-			},
-		},
-		{
-			note: "Foo:bar\nbaz\n",
-			want: map[string]string{
-				"foo": "bar\nbaz\n",
-			},
-		},
-		{
-			note: "NoteType:SSH Key\nLanguage:en-US\nBit Strength:2048\nFormat:RSA\nPassphrase:Passphrase\nPrivate Key:-----BEGIN OPENSSH PRIVATE KEY-----\n-----END OPENSSH PRIVATE KEY-----\nPublic Key:ssh-rsa public-key user@host\nHostname:Hostname\nDate:Date\nNotes:",
-			want: map[string]string{
-				"noteType":    "SSH Key\n",
-				"language":    "en-US\n",
-				"bitStrength": "2048\n",
-				"format":      "RSA\n",
-				"passphrase":  "Passphrase\n",
-				"privateKey":  "-----BEGIN OPENSSH PRIVATE KEY-----\n-----END OPENSSH PRIVATE KEY-----\n",
-				"publicKey":   "ssh-rsa public-key user@host\n",
-				"hostname":    "Hostname\n",
-				"date":        "Date\n",
-				"notes":       "\n",
-			},
-		},
-	} {
-		got := lastpassParseNote(tc.note)
-		if diff, equal := messagediff.PrettyDiff(tc.want, got); !equal {
-			t.Errorf("lastpassParseNote(%q) == %+v, want %+v\n%s", tc.note, got, tc.want, diff)
-		}
-	}
-}
diff --git a/lib/chezmoi/chezmoi_test.go b/lib/chezmoi/chezmoi_test.go
index fdd8a7e66..9dfe673bf 100644
--- a/lib/chezmoi/chezmoi_test.go
+++ b/lib/chezmoi/chezmoi_test.go
@@ -2,407 +2,9 @@ package chezmoi
 
 import (
 	"errors"
-	"os"
 	"testing"
-	"text/template"
-
-	"github.com/d4l3k/messagediff"
-	"github.com/twpayne/go-vfs/vfst"
 )
 
-func TestSourceDirName(t *testing.T) {
-	for _, tc := range []struct {
-		sourceDirName string
-		psdn          ParsedSourceDirName
-	}{
-		{
-			sourceDirName: "foo",
-			psdn: ParsedSourceDirName{
-				DirName: "foo",
-				Perm:    0777,
-			},
-		},
-		{
-			sourceDirName: "dot_foo",
-			psdn: ParsedSourceDirName{
-				DirName: ".foo",
-				Perm:    0777,
-			},
-		},
-		{
-			sourceDirName: "private_foo",
-			psdn: ParsedSourceDirName{
-				DirName: "foo",
-				Perm:    0700,
-			},
-		},
-		{
-			sourceDirName: "private_dot_foo",
-			psdn: ParsedSourceDirName{
-				DirName: ".foo",
-				Perm:    0700,
-			},
-		},
-	} {
-		t.Run(tc.sourceDirName, func(t *testing.T) {
-			gotPSDN := ParseSourceDirName(tc.sourceDirName)
-			if diff, equal := messagediff.PrettyDiff(tc.psdn, gotPSDN); !equal {
-				t.Errorf("ParseSourceDirName(%q) == %+v, want %+v, diff:\n%s", tc.sourceDirName, gotPSDN, tc.psdn, diff)
-			}
-			if gotSourceDirName := tc.psdn.SourceDirName(); gotSourceDirName != tc.sourceDirName {
-				t.Errorf("%+v.SourceDirName() == %q, want %q", tc.psdn, gotSourceDirName, tc.sourceDirName)
-			}
-		})
-	}
-}
-
-func TestSourceFileName(t *testing.T) {
-	for _, tc := range []struct {
-		sourceFileName string
-		psfn           ParsedSourceFileName
-	}{
-		{
-			sourceFileName: "foo",
-			psfn: ParsedSourceFileName{
-				FileName: "foo",
-				Mode:     0666,
-				Empty:    false,
-				Template: false,
-			},
-		},
-		{
-			sourceFileName: "dot_foo",
-			psfn: ParsedSourceFileName{
-				FileName: ".foo",
-				Mode:     0666,
-				Empty:    false,
-				Template: false,
-			},
-		},
-		{
-			sourceFileName: "private_foo",
-			psfn: ParsedSourceFileName{
-				FileName: "foo",
-				Mode:     0600,
-				Empty:    false,
-				Template: false,
-			},
-		},
-		{
-			sourceFileName: "private_dot_foo",
-			psfn: ParsedSourceFileName{
-				FileName: ".foo",
-				Mode:     0600,
-				Empty:    false,
-				Template: false,
-			},
-		},
-		{
-			sourceFileName: "empty_foo",
-			psfn: ParsedSourceFileName{
-				FileName: "foo",
-				Mode:     0666,
-				Empty:    true,
-				Template: false,
-			},
-		},
-		{
-			sourceFileName: "executable_foo",
-			psfn: ParsedSourceFileName{
-				FileName: "foo",
-				Mode:     0777,
-				Empty:    false,
-				Template: false,
-			},
-		},
-		{
-			sourceFileName: "foo.tmpl",
-			psfn: ParsedSourceFileName{
-				FileName: "foo",
-				Mode:     0666,
-				Empty:    false,
-				Template: true,
-			},
-		},
-		{
-			sourceFileName: "private_executable_dot_foo.tmpl",
-			psfn: ParsedSourceFileName{
-				FileName: ".foo",
-				Mode:     0700,
-				Empty:    false,
-				Template: true,
-			},
-		},
-		{
-			sourceFileName: "symlink_foo",
-			psfn: ParsedSourceFileName{
-				FileName: "foo",
-				Mode:     os.ModeSymlink | 0666,
-			},
-		},
-		{
-			sourceFileName: "symlink_dot_foo",
-			psfn: ParsedSourceFileName{
-				FileName: ".foo",
-				Mode:     os.ModeSymlink | 0666,
-			},
-		},
-		{
-			sourceFileName: "symlink_foo.tmpl",
-			psfn: ParsedSourceFileName{
-				FileName: "foo",
-				Mode:     os.ModeSymlink | 0666,
-				Template: true,
-			},
-		},
-	} {
-		t.Run(tc.sourceFileName, func(t *testing.T) {
-			gotPSFN := ParseSourceFileName(tc.sourceFileName)
-			if diff, equal := messagediff.PrettyDiff(tc.psfn, gotPSFN); !equal {
-				t.Errorf("ParseSourceFileName(%q) == %+v, want %+v, diff:\n%s", tc.sourceFileName, gotPSFN, tc.psfn, diff)
-			}
-			if gotSourceFileName := tc.psfn.SourceFileName(); gotSourceFileName != tc.sourceFileName {
-				t.Errorf("%+v.SourceFileName() == %q, want %q", tc.psfn, gotSourceFileName, tc.sourceFileName)
-			}
-		})
-	}
-}
-
-func TestTargetStatePopulate(t *testing.T) {
-	for _, tc := range []struct {
-		name      string
-		root      interface{}
-		sourceDir string
-		data      map[string]interface{}
-		funcs     template.FuncMap
-		want      *TargetState
-	}{
-		{
-			name: "simple_file",
-			root: map[string]string{
-				"/foo": "bar",
-			},
-			sourceDir: "/",
-			want: &TargetState{
-				TargetDir: "/",
-				Umask:     0,
-				SourceDir: "/",
-				Entries: map[string]Entry{
-					"foo": &File{
-						sourceName: "foo",
-						targetName: "foo",
-						Perm:       0666,
-						contents:   []byte("bar"),
-					},
-				},
-			},
-		},
-		{
-			name: "dot_file",
-			root: map[string]string{
-				"/dot_foo": "bar",
-			},
-			sourceDir: "/",
-			want: &TargetState{
-				TargetDir: "/",
-				Umask:     0,
-				SourceDir: "/",
-				Entries: map[string]Entry{
-					".foo": &File{
-						sourceName: "dot_foo",
-						targetName: ".foo",
-						Perm:       0666,
-						contents:   []byte("bar"),
-					},
-				},
-			},
-		},
-		{
-			name: "private_file",
-			root: map[string]string{
-				"/private_foo": "bar",
-			},
-			sourceDir: "/",
-			want: &TargetState{
-				TargetDir: "/",
-				Umask:     0,
-				SourceDir: "/",
-				Entries: map[string]Entry{
-					"foo": &File{
-						sourceName: "private_foo",
-						targetName: "foo",
-						Perm:       0600,
-						contents:   []byte("bar"),
-					},
-				},
-			},
-		},
-		{
-			name: "file_in_subdir",
-			root: map[string]string{
-				"/foo/bar": "baz",
-			},
-			sourceDir: "/",
-			want: &TargetState{
-				TargetDir: "/",
-				Umask:     0,
-				SourceDir: "/",
-				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"),
-							},
-						},
-					},
-				},
-			},
-		},
-		{
-			name: "file_in_private_dot_subdir",
-			root: map[string]string{
-				"/private_dot_foo/bar": "baz",
-			},
-			sourceDir: "/",
-			want: &TargetState{
-				TargetDir: "/",
-				Umask:     0,
-				SourceDir: "/",
-				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"),
-							},
-						},
-					},
-				},
-			},
-		},
-		{
-			name: "template_dot_file",
-			root: map[string]string{
-				"/dot_gitconfig.tmpl": "[user]\n\temail = {{.Email}}\n",
-			},
-			sourceDir: "/",
-			data: map[string]interface{}{
-				"Email": "user@example.com",
-			},
-			want: &TargetState{
-				TargetDir: "/",
-				Umask:     0,
-				SourceDir: "/",
-				Data: map[string]interface{}{
-					"Email": "user@example.com",
-				},
-				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"),
-					},
-				},
-			},
-		},
-		{
-			name: "symlink",
-			root: map[string]interface{}{
-				"/symlink_foo": "bar",
-			},
-			sourceDir: "/",
-			want: &TargetState{
-				TargetDir: "/",
-				Umask:     0,
-				SourceDir: "/",
-				Entries: map[string]Entry{
-					"foo": &Symlink{
-						sourceName: "symlink_foo",
-						targetName: "foo",
-						linkname:   "bar",
-					},
-				},
-			},
-		},
-		{
-			name: "symlink_dot_foo",
-			root: map[string]interface{}{
-				"/symlink_dot_foo": "bar",
-			},
-			sourceDir: "/",
-			want: &TargetState{
-				TargetDir: "/",
-				Umask:     0,
-				SourceDir: "/",
-				Entries: map[string]Entry{
-					".foo": &Symlink{
-						sourceName: "symlink_dot_foo",
-						targetName: ".foo",
-						linkname:   "bar",
-					},
-				},
-			},
-		},
-		{
-			name: "symlink_template",
-			root: map[string]interface{}{
-				"/symlink_foo.tmpl": "bar-{{ .host }}",
-			},
-			sourceDir: "/",
-			data: map[string]interface{}{
-				"host": "example.com",
-			},
-			want: &TargetState{
-				TargetDir: "/",
-				Umask:     0,
-				SourceDir: "/",
-				Data: map[string]interface{}{
-					"host": "example.com",
-				},
-				Entries: map[string]Entry{
-					"foo": &Symlink{
-						sourceName: "symlink_foo.tmpl",
-						targetName: "foo",
-						Template:   true,
-						linkname:   "bar-example.com",
-					},
-				},
-			},
-		},
-	} {
-		t.Run(tc.name, func(t *testing.T) {
-			fs, cleanup, err := vfst.NewTestFS(tc.root)
-			defer cleanup()
-			if err != nil {
-				t.Fatalf("vfst.NewTestFS(_) == _, _, %v, want _, _, <nil>", err)
-			}
-			ts := NewTargetState("/", 0, tc.sourceDir, tc.data, tc.funcs)
-			if err := ts.Populate(fs); err != nil {
-				t.Fatalf("ts.Populate(%+v) == %v, want <nil>", fs, err)
-			}
-			if err := ts.Evaluate(); err != nil {
-				t.Errorf("ts.Evaluate() == %v, want <nil>", err)
-			}
-			if diff, equal := messagediff.PrettyDiff(tc.want, ts); !equal {
-				t.Errorf("ts.Populate(%+v) diff:\n%s\n", fs, diff)
-			}
-		})
-	}
-}
-
 func TestReturnTemplateError(t *testing.T) {
 	funcs := map[string]interface{}{
 		"returnTemplateError": func() string {
@@ -424,61 +26,3 @@ func TestReturnTemplateError(t *testing.T) {
 		})
 	}
 }
-
-func TestEndToEnd(t *testing.T) {
-	for _, tc := range []struct {
-		name      string
-		root      interface{}
-		sourceDir string
-		data      map[string]interface{}
-		funcs     template.FuncMap
-		targetDir string
-		umask     os.FileMode
-		tests     interface{}
-	}{
-		{
-			name: "all",
-			root: map[string]interface{}{
-				"/home/user/.bashrc":                          "foo",
-				"/home/user/replace_symlink":                  &vfst.Symlink{Target: "foo"},
-				"/home/user/.chezmoi/dot_bashrc":              "bar",
-				"/home/user/.chezmoi/.git/HEAD":               "HEAD",
-				"/home/user/.chezmoi/dot_hgrc.tmpl":           "[ui]\nusername = {{ .name }} <{{ .email }}>\n",
-				"/home/user/.chezmoi/empty.tmpl":              "{{ if false }}foo{{ end }}",
-				"/home/user/.chezmoi/empty_foo":               "",
-				"/home/user/.chezmoi/symlink_bar":             "empty",
-				"/home/user/.chezmoi/symlink_replace_symlink": "bar",
-			},
-			sourceDir: "/home/user/.chezmoi",
-			data: map[string]interface{}{
-				"name":  "John Smith",
-				"email": "hello@example.com",
-			},
-			targetDir: "/home/user",
-			umask:     022,
-			tests: []vfst.Test{
-				vfst.TestPath("/home/user/.bashrc", vfst.TestModeIsRegular, vfst.TestContentsString("bar")),
-				vfst.TestPath("/home/user/.hgrc", vfst.TestModeIsRegular, vfst.TestContentsString("[ui]\nusername = John Smith <hello@example.com>\n")),
-				vfst.TestPath("/home/user/foo", vfst.TestModeIsRegular, vfst.TestContents(nil)),
-				vfst.TestPath("/home/user/bar", vfst.TestModeType(os.ModeSymlink), vfst.TestSymlinkTarget("empty")),
-				vfst.TestPath("/home/user/replace_symlink", vfst.TestModeType(os.ModeSymlink), vfst.TestSymlinkTarget("bar")),
-			},
-		},
-	} {
-		t.Run(tc.name, func(t *testing.T) {
-			fs, cleanup, err := vfst.NewTestFS(tc.root)
-			defer cleanup()
-			if err != nil {
-				t.Fatalf("vfst.NewTestFS(_) == _, _, %v, want _, _, <nil>", err)
-			}
-			ts := NewTargetState(tc.targetDir, tc.umask, tc.sourceDir, tc.data, tc.funcs)
-			if err := ts.Populate(fs); err != nil {
-				t.Fatalf("ts.Populate(%+v) == %v, want <nil>", fs, err)
-			}
-			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)
-		})
-	}
-}
diff --git a/lib/chezmoi/dir_test.go b/lib/chezmoi/dir_test.go
new file mode 100644
index 000000000..973a7cab6
--- /dev/null
+++ b/lib/chezmoi/dir_test.go
@@ -0,0 +1,53 @@
+package chezmoi
+
+import (
+	"testing"
+
+	"github.com/d4l3k/messagediff"
+)
+
+func TestDirAttributes(t *testing.T) {
+	for _, tc := range []struct {
+		sourceName string
+		da         DirAttributes
+	}{
+		{
+			sourceName: "foo",
+			da: DirAttributes{
+				Name: "foo",
+				Perm: 0777,
+			},
+		},
+		{
+			sourceName: "dot_foo",
+			da: DirAttributes{
+				Name: ".foo",
+				Perm: 0777,
+			},
+		},
+		{
+			sourceName: "private_foo",
+			da: DirAttributes{
+				Name: "foo",
+				Perm: 0700,
+			},
+		},
+		{
+			sourceName: "private_dot_foo",
+			da: DirAttributes{
+				Name: ".foo",
+				Perm: 0700,
+			},
+		},
+	} {
+		t.Run(tc.sourceName, func(t *testing.T) {
+			gotDA := ParseDirAttributes(tc.sourceName)
+			if diff, equal := messagediff.PrettyDiff(tc.da, gotDA); !equal {
+				t.Errorf("ParseDirAttributes(%q) == %+v, want %+v, diff:\n%s", tc.sourceName, gotDA, tc.da, diff)
+			}
+			if gotSourceName := tc.da.SourceName(); gotSourceName != tc.sourceName {
+				t.Errorf("%+v.SourceName() == %q, want %q", tc.da, gotSourceName, tc.sourceName)
+			}
+		})
+	}
+}
diff --git a/lib/chezmoi/file_test.go b/lib/chezmoi/file_test.go
new file mode 100644
index 000000000..3901b2a61
--- /dev/null
+++ b/lib/chezmoi/file_test.go
@@ -0,0 +1,120 @@
+package chezmoi
+
+import (
+	"os"
+	"testing"
+
+	"github.com/d4l3k/messagediff"
+)
+
+func TestFileAttributes(t *testing.T) {
+	for _, tc := range []struct {
+		sourceName string
+		fa         FileAttributes
+	}{
+		{
+			sourceName: "foo",
+			fa: FileAttributes{
+				Name:     "foo",
+				Mode:     0666,
+				Empty:    false,
+				Template: false,
+			},
+		},
+		{
+			sourceName: "dot_foo",
+			fa: FileAttributes{
+				Name:     ".foo",
+				Mode:     0666,
+				Empty:    false,
+				Template: false,
+			},
+		},
+		{
+			sourceName: "private_foo",
+			fa: FileAttributes{
+				Name:     "foo",
+				Mode:     0600,
+				Empty:    false,
+				Template: false,
+			},
+		},
+		{
+			sourceName: "private_dot_foo",
+			fa: FileAttributes{
+				Name:     ".foo",
+				Mode:     0600,
+				Empty:    false,
+				Template: false,
+			},
+		},
+		{
+			sourceName: "empty_foo",
+			fa: FileAttributes{
+				Name:     "foo",
+				Mode:     0666,
+				Empty:    true,
+				Template: false,
+			},
+		},
+		{
+			sourceName: "executable_foo",
+			fa: FileAttributes{
+				Name:     "foo",
+				Mode:     0777,
+				Empty:    false,
+				Template: false,
+			},
+		},
+		{
+			sourceName: "foo.tmpl",
+			fa: FileAttributes{
+				Name:     "foo",
+				Mode:     0666,
+				Empty:    false,
+				Template: true,
+			},
+		},
+		{
+			sourceName: "private_executable_dot_foo.tmpl",
+			fa: FileAttributes{
+				Name:     ".foo",
+				Mode:     0700,
+				Empty:    false,
+				Template: true,
+			},
+		},
+		{
+			sourceName: "symlink_foo",
+			fa: FileAttributes{
+				Name: "foo",
+				Mode: os.ModeSymlink | 0666,
+			},
+		},
+		{
+			sourceName: "symlink_dot_foo",
+			fa: FileAttributes{
+				Name: ".foo",
+				Mode: os.ModeSymlink | 0666,
+			},
+		},
+		{
+			sourceName: "symlink_foo.tmpl",
+			fa: FileAttributes{
+				Name:     "foo",
+				Mode:     os.ModeSymlink | 0666,
+				Template: true,
+			},
+		},
+	} {
+		t.Run(tc.sourceName, func(t *testing.T) {
+			gotFA := ParseFileAttributes(tc.sourceName)
+			if diff, equal := messagediff.PrettyDiff(tc.fa, gotFA); !equal {
+				t.Errorf("ParseFileAttributes(%q) == %+v, want %+v, diff:\n%s", tc.sourceName, gotFA, tc.fa, diff)
+			}
+			if gotSourceName := tc.fa.SourceName(); gotSourceName != tc.sourceName {
+				t.Errorf("%+v.SourceName() == %q, want %q", tc.fa, gotSourceName, tc.sourceName)
+			}
+		})
+	}
+}
diff --git a/lib/chezmoi/target_state_test.go b/lib/chezmoi/target_state_test.go
new file mode 100644
index 000000000..23247a6c4
--- /dev/null
+++ b/lib/chezmoi/target_state_test.go
@@ -0,0 +1,303 @@
+package chezmoi
+
+import (
+	"os"
+	"testing"
+	"text/template"
+
+	"github.com/d4l3k/messagediff"
+	"github.com/twpayne/go-vfs/vfst"
+)
+
+func TestTargetStatePopulate(t *testing.T) {
+	for _, tc := range []struct {
+		name      string
+		root      interface{}
+		sourceDir string
+		data      map[string]interface{}
+		funcs     template.FuncMap
+		want      *TargetState
+	}{
+		{
+			name: "simple_file",
+			root: map[string]string{
+				"/foo": "bar",
+			},
+			sourceDir: "/",
+			want: &TargetState{
+				TargetDir: "/",
+				Umask:     0,
+				SourceDir: "/",
+				Entries: map[string]Entry{
+					"foo": &File{
+						sourceName: "foo",
+						targetName: "foo",
+						Perm:       0666,
+						contents:   []byte("bar"),
+					},
+				},
+			},
+		},
+		{
+			name: "dot_file",
+			root: map[string]string{
+				"/dot_foo": "bar",
+			},
+			sourceDir: "/",
+			want: &TargetState{
+				TargetDir: "/",
+				Umask:     0,
+				SourceDir: "/",
+				Entries: map[string]Entry{
+					".foo": &File{
+						sourceName: "dot_foo",
+						targetName: ".foo",
+						Perm:       0666,
+						contents:   []byte("bar"),
+					},
+				},
+			},
+		},
+		{
+			name: "private_file",
+			root: map[string]string{
+				"/private_foo": "bar",
+			},
+			sourceDir: "/",
+			want: &TargetState{
+				TargetDir: "/",
+				Umask:     0,
+				SourceDir: "/",
+				Entries: map[string]Entry{
+					"foo": &File{
+						sourceName: "private_foo",
+						targetName: "foo",
+						Perm:       0600,
+						contents:   []byte("bar"),
+					},
+				},
+			},
+		},
+		{
+			name: "file_in_subdir",
+			root: map[string]string{
+				"/foo/bar": "baz",
+			},
+			sourceDir: "/",
+			want: &TargetState{
+				TargetDir: "/",
+				Umask:     0,
+				SourceDir: "/",
+				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"),
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "file_in_private_dot_subdir",
+			root: map[string]string{
+				"/private_dot_foo/bar": "baz",
+			},
+			sourceDir: "/",
+			want: &TargetState{
+				TargetDir: "/",
+				Umask:     0,
+				SourceDir: "/",
+				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"),
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "template_dot_file",
+			root: map[string]string{
+				"/dot_gitconfig.tmpl": "[user]\n\temail = {{.Email}}\n",
+			},
+			sourceDir: "/",
+			data: map[string]interface{}{
+				"Email": "user@example.com",
+			},
+			want: &TargetState{
+				TargetDir: "/",
+				Umask:     0,
+				SourceDir: "/",
+				Data: map[string]interface{}{
+					"Email": "user@example.com",
+				},
+				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"),
+					},
+				},
+			},
+		},
+		{
+			name: "symlink",
+			root: map[string]interface{}{
+				"/symlink_foo": "bar",
+			},
+			sourceDir: "/",
+			want: &TargetState{
+				TargetDir: "/",
+				Umask:     0,
+				SourceDir: "/",
+				Entries: map[string]Entry{
+					"foo": &Symlink{
+						sourceName: "symlink_foo",
+						targetName: "foo",
+						linkname:   "bar",
+					},
+				},
+			},
+		},
+		{
+			name: "symlink_dot_foo",
+			root: map[string]interface{}{
+				"/symlink_dot_foo": "bar",
+			},
+			sourceDir: "/",
+			want: &TargetState{
+				TargetDir: "/",
+				Umask:     0,
+				SourceDir: "/",
+				Entries: map[string]Entry{
+					".foo": &Symlink{
+						sourceName: "symlink_dot_foo",
+						targetName: ".foo",
+						linkname:   "bar",
+					},
+				},
+			},
+		},
+		{
+			name: "symlink_template",
+			root: map[string]interface{}{
+				"/symlink_foo.tmpl": "bar-{{ .host }}",
+			},
+			sourceDir: "/",
+			data: map[string]interface{}{
+				"host": "example.com",
+			},
+			want: &TargetState{
+				TargetDir: "/",
+				Umask:     0,
+				SourceDir: "/",
+				Data: map[string]interface{}{
+					"host": "example.com",
+				},
+				Entries: map[string]Entry{
+					"foo": &Symlink{
+						sourceName: "symlink_foo.tmpl",
+						targetName: "foo",
+						Template:   true,
+						linkname:   "bar-example.com",
+					},
+				},
+			},
+		},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			fs, cleanup, err := vfst.NewTestFS(tc.root)
+			defer cleanup()
+			if err != nil {
+				t.Fatalf("vfst.NewTestFS(_) == _, _, %v, want _, _, <nil>", err)
+			}
+			ts := NewTargetState("/", 0, tc.sourceDir, tc.data, tc.funcs)
+			if err := ts.Populate(fs); err != nil {
+				t.Fatalf("ts.Populate(%+v) == %v, want <nil>", fs, err)
+			}
+			if err := ts.Evaluate(); err != nil {
+				t.Errorf("ts.Evaluate() == %v, want <nil>", err)
+			}
+			if diff, equal := messagediff.PrettyDiff(tc.want, ts); !equal {
+				t.Errorf("ts.Populate(%+v) diff:\n%s\n", fs, diff)
+			}
+		})
+	}
+}
+
+func TestEndToEnd(t *testing.T) {
+	for _, tc := range []struct {
+		name      string
+		root      interface{}
+		sourceDir string
+		data      map[string]interface{}
+		funcs     template.FuncMap
+		targetDir string
+		umask     os.FileMode
+		tests     interface{}
+	}{
+		{
+			name: "all",
+			root: map[string]interface{}{
+				"/home/user/.bashrc":                          "foo",
+				"/home/user/replace_symlink":                  &vfst.Symlink{Target: "foo"},
+				"/home/user/.chezmoi/dot_bashrc":              "bar",
+				"/home/user/.chezmoi/.git/HEAD":               "HEAD",
+				"/home/user/.chezmoi/dot_hgrc.tmpl":           "[ui]\nusername = {{ .name }} <{{ .email }}>\n",
+				"/home/user/.chezmoi/empty.tmpl":              "{{ if false }}foo{{ end }}",
+				"/home/user/.chezmoi/empty_foo":               "",
+				"/home/user/.chezmoi/symlink_bar":             "empty",
+				"/home/user/.chezmoi/symlink_replace_symlink": "bar",
+			},
+			sourceDir: "/home/user/.chezmoi",
+			data: map[string]interface{}{
+				"name":  "John Smith",
+				"email": "hello@example.com",
+			},
+			targetDir: "/home/user",
+			umask:     022,
+			tests: []vfst.Test{
+				vfst.TestPath("/home/user/.bashrc", vfst.TestModeIsRegular, vfst.TestContentsString("bar")),
+				vfst.TestPath("/home/user/.hgrc", vfst.TestModeIsRegular, vfst.TestContentsString("[ui]\nusername = John Smith <hello@example.com>\n")),
+				vfst.TestPath("/home/user/foo", vfst.TestModeIsRegular, vfst.TestContents(nil)),
+				vfst.TestPath("/home/user/bar", vfst.TestModeType(os.ModeSymlink), vfst.TestSymlinkTarget("empty")),
+				vfst.TestPath("/home/user/replace_symlink", vfst.TestModeType(os.ModeSymlink), vfst.TestSymlinkTarget("bar")),
+			},
+		},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			fs, cleanup, err := vfst.NewTestFS(tc.root)
+			defer cleanup()
+			if err != nil {
+				t.Fatalf("vfst.NewTestFS(_) == _, _, %v, want _, _, <nil>", err)
+			}
+			ts := NewTargetState(tc.targetDir, tc.umask, tc.sourceDir, tc.data, tc.funcs)
+			if err := ts.Populate(fs); err != nil {
+				t.Fatalf("ts.Populate(%+v) == %v, want <nil>", fs, err)
+			}
+			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)
+		})
+	}
+}