Repository: twpayne/chezmoi
Pull request #87 (2019-01-05 14:00:57)

Add support for exact directories


Patch:
diff --git a/README.md b/README.md
index b8fc7b300..25ba10036 100644
--- a/README.md
+++ b/README.md
@@ -395,19 +395,20 @@ with a `.`. The following prefixes and suffixes are special.
 | -------------------- | ----------------------------------------------------------------------------------|
 | `private_` prefix    | Remove all group and world permissions from the target file or directory.         |
 | `empty_` prefix      | Ensure the file exists, even if is empty. By default, empty files are removed.    |
+| `exact_` prefix      | Remove anything not managed by `chezmoi`.                                         |
 | `executable_` prefix | Add executable permissions to the target file.                                    |
 | `symlink_` prefix    | Create a symlink instead of a regular file.                                       |
 | `dot_` prefix        | Rename to use a leading dot, e.g. `dot_foo` becomes `.foo`.                       |
 | `.tmpl` suffix       | Treat the contents of the source file as a template.                              |
 
-Order is important, the order is `private_`, `empty_`, `executable_`,
+Order is important, the order is `exact_`, `private_`, `empty_`, `executable_`,
 `symlink_`, `dot_`, `.tmpl`.
 
 Different target types allow different prefixes and suffixes.
 
 | Target type   | Allowed prefixes and suffixes                        |
 | ------------- | ---------------------------------------------------- |
-| Directory     | `private_`, `dot_`                                   |
+| Directory     | `exact_, `private_`, `dot_`                          |
 | Regular file  | `private_`, `empty_`, `executable_`, `dot_`, `.tmpl` |
 | Symbolic link | `symlink_`, `dot_`, `.tmpl`                          |
 
diff --git a/cmd/add.go b/cmd/add.go
index 09a8480f3..6a70f4f38 100644
--- a/cmd/add.go
+++ b/cmd/add.go
@@ -6,6 +6,7 @@ import (
 	"path/filepath"
 
 	"github.com/spf13/cobra"
+	"github.com/twpayne/chezmoi/lib/chezmoi"
 	vfs "github.com/twpayne/go-vfs"
 )
 
@@ -16,20 +17,19 @@ var addCommand = &cobra.Command{
 	RunE:  makeRunE(config.runAddCommand),
 }
 
-// An AddCommandConfig is a configuration for the add command.
 type addCommandConfig struct {
-	empty     bool
 	recursive bool
-	template  bool
+	options   chezmoi.AddOptions
 }
 
 func init() {
 	rootCommand.AddCommand(addCommand)
 
 	persistentFlags := addCommand.PersistentFlags()
-	persistentFlags.BoolVarP(&config.add.empty, "empty", "e", false, "add empty files")
+	persistentFlags.BoolVarP(&config.add.options.Empty, "empty", "e", false, "add empty files")
+	persistentFlags.BoolVarP(&config.add.options.Exact, "exact", "x", false, "add directories exactly")
 	persistentFlags.BoolVarP(&config.add.recursive, "recursive", "r", false, "recurse in to subdirectories")
-	persistentFlags.BoolVarP(&config.add.template, "template", "T", false, "add files as templates")
+	persistentFlags.BoolVarP(&config.add.options.Template, "template", "T", false, "add files as templates")
 }
 
 func (c *Config) runAddCommand(fs vfs.FS, command *cobra.Command, args []string) error {
@@ -65,12 +65,12 @@ func (c *Config) runAddCommand(fs vfs.FS, command *cobra.Command, args []string)
 				if err != nil {
 					return err
 				}
-				return ts.Add(fs, path, info, c.add.empty, c.add.template, mutator)
+				return ts.Add(fs, c.add.options, path, info, mutator)
 			}); err != nil {
 				return err
 			}
 		} else {
-			if err := ts.Add(fs, path, nil, c.add.empty, c.add.template, mutator); err != nil {
+			if err := ts.Add(fs, c.add.options, path, nil, mutator); err != nil {
 				return err
 			}
 		}
diff --git a/cmd/chattr.go b/cmd/chattr.go
index ee886875c..1b241cb04 100644
--- a/cmd/chattr.go
+++ b/cmd/chattr.go
@@ -20,7 +20,7 @@ import (
 var chattrCommand = &cobra.Command{
 	Use:   "chattr",
 	Args:  cobra.MinimumNArgs(2),
-	Short: "Change the private, empty, executable, or template attributes of a target",
+	Short: "Change the exact, private, empty, executable, or template attributes of a target",
 	RunE:  makeRunE(config.runChattrCommand),
 }
 
@@ -28,6 +28,7 @@ type boolModifier int
 
 type attributeModifiers struct {
 	empty      boolModifier
+	exact      boolModifier
 	executable boolModifier
 	private    boolModifier
 	template   boolModifier
@@ -57,6 +58,7 @@ func (c *Config) runChattrCommand(fs vfs.FS, cmd *cobra.Command, args []string)
 		switch entry := entry.(type) {
 		case *chezmoi.Dir:
 			da := chezmoi.ParseDirAttributes(oldSourceName)
+			da.Exact = ams.exact.modify(entry.Exact)
 			if private := ams.private.modify(entry.Private()); private {
 				da.Perm &= 0700
 			}
@@ -119,6 +121,8 @@ func parseAttributeModifiers(s string) (*attributeModifiers, error) {
 		switch attribute {
 		case "empty", "e":
 			ams.empty = modifier
+		case "exact":
+			ams.exact = modifier
 		case "executable", "x":
 			ams.executable = modifier
 		case "private", "p":
diff --git a/cmd/import.go b/cmd/import.go
index 7cadeaad9..921a19415 100644
--- a/cmd/import.go
+++ b/cmd/import.go
@@ -16,6 +16,7 @@ import (
 	"strings"
 
 	"github.com/spf13/cobra"
+	"github.com/twpayne/chezmoi/lib/chezmoi"
 	vfs "github.com/twpayne/go-vfs"
 )
 
@@ -27,17 +28,17 @@ var _importCommand = &cobra.Command{
 }
 
 type importCommandConfig struct {
-	destination       string
 	removeDestination bool
-	stripComponents   int
+	importTAROptions  chezmoi.ImportTAROptions
 }
 
 func init() {
 	rootCommand.AddCommand(_importCommand)
 
 	persistentFlags := _importCommand.PersistentFlags()
-	persistentFlags.StringVarP(&config._import.destination, "destination", "d", "", "destination prefix")
-	persistentFlags.IntVar(&config._import.stripComponents, "strip-components", 0, "strip components")
+	persistentFlags.StringVarP(&config._import.importTAROptions.DestinationDir, "destination", "d", "", "destination prefix")
+	persistentFlags.BoolVarP(&config._import.importTAROptions.Exact, "exact", "x", false, "import directories exactly")
+	persistentFlags.IntVar(&config._import.importTAROptions.StripComponents, "strip-components", 0, "strip components")
 	persistentFlags.BoolVarP(&config._import.removeDestination, "remove-destination", "r", false, "remove destination before import")
 }
 
@@ -72,7 +73,7 @@ func (c *Config) runImportCommand(fs vfs.FS, cmd *cobra.Command, args []string)
 	}
 	mutator := c.getDefaultMutator(fs)
 	if c._import.removeDestination {
-		entry, err := ts.Get(c._import.destination)
+		entry, err := ts.Get(c._import.importTAROptions.DestinationDir)
 		switch {
 		case err == nil:
 			if err := mutator.RemoveAll(filepath.Join(c.SourceDir, entry.SourceName())); err != nil {
@@ -83,5 +84,5 @@ func (c *Config) runImportCommand(fs vfs.FS, cmd *cobra.Command, args []string)
 			return err
 		}
 	}
-	return ts.ImportTAR(tar.NewReader(r), c._import.destination, c._import.stripComponents, mutator)
+	return ts.ImportTAR(tar.NewReader(r), c._import.importTAROptions, mutator)
 }
diff --git a/lib/chezmoi/chezmoi.go b/lib/chezmoi/chezmoi.go
index 8fb7029c5..ebf8ea409 100644
--- a/lib/chezmoi/chezmoi.go
+++ b/lib/chezmoi/chezmoi.go
@@ -14,6 +14,7 @@ const (
 	symlinkPrefix    = "symlink_"
 	privatePrefix    = "private_"
 	emptyPrefix      = "empty_"
+	exactPrefix      = "exact_"
 	executablePrefix = "executable_"
 	dotPrefix        = "dot_"
 	templateSuffix   = ".tmpl"
@@ -37,7 +38,7 @@ type Entry interface {
 
 type parsedSourceFilePath struct {
 	FileAttributes
-	dirNames []string
+	dirAttributes []DirAttributes
 }
 
 // ReturnTemplateFuncError causes template execution to return an error.
@@ -47,27 +48,33 @@ func ReturnTemplateFuncError(err error) {
 	})
 }
 
-// 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{}
+// parseDirNameComponents parses multiple directory name components.
+func parseDirNameComponents(components []string) []DirAttributes {
+	das := []DirAttributes{}
 	for _, component := range components {
 		da := ParseDirAttributes(component)
-		dirNames = append(dirNames, da.Name)
-		perms = append(perms, da.Perm)
+		das = append(das, da)
 	}
-	return dirNames, perms
+	return das
+}
+
+// dirNames returns the dir names from dirAttributes.
+func dirNames(dirAttributes []DirAttributes) []string {
+	dns := []string{}
+	for _, da := range dirAttributes {
+		dns = append(dns, da.Name)
+	}
+	return dns
 }
 
 // parseSourceFilePath parses a single source file path.
 func parseSourceFilePath(path string) parsedSourceFilePath {
 	components := splitPathList(path)
-	dirNames, _ := parseDirNameComponents(components[0 : len(components)-1])
+	das := parseDirNameComponents(components[0 : len(components)-1])
 	fa := ParseFileAttributes(components[len(components)-1])
 	return parsedSourceFilePath{
 		FileAttributes: fa,
-		dirNames:       dirNames,
+		dirAttributes:  das,
 	}
 }
 
diff --git a/lib/chezmoi/dir.go b/lib/chezmoi/dir.go
index 7fc77838f..6bc152b0e 100644
--- a/lib/chezmoi/dir.go
+++ b/lib/chezmoi/dir.go
@@ -11,14 +11,16 @@ import (
 
 // DirAttributes holds attributes parsed from a source directory name.
 type DirAttributes struct {
-	Name string
-	Perm os.FileMode
+	Name  string
+	Exact bool
+	Perm  os.FileMode
 }
 
 // A Dir represents the target state of a directory.
 type Dir struct {
 	sourceName string
 	targetName string
+	Exact      bool
 	Perm       os.FileMode
 	Entries    map[string]Entry
 }
@@ -27,6 +29,7 @@ type dirConcreteValue struct {
 	Type       string        `json:"type" yaml:"type"`
 	SourcePath string        `json:"sourcePath" yaml:"sourcePath"`
 	TargetPath string        `json:"targetPath" yaml:"targetPath"`
+	Exact      bool          `json:"exact" yaml:"exact"`
 	Perm       int           `json:"perm" yaml:"perm"`
 	Entries    []interface{} `json:"entries" yaml:"entries"`
 }
@@ -35,6 +38,11 @@ type dirConcreteValue struct {
 func ParseDirAttributes(sourceName string) DirAttributes {
 	name := sourceName
 	perm := os.FileMode(0777)
+	exact := false
+	if strings.HasPrefix(name, exactPrefix) {
+		name = strings.TrimPrefix(name, exactPrefix)
+		exact = true
+	}
 	if strings.HasPrefix(name, privatePrefix) {
 		name = strings.TrimPrefix(name, privatePrefix)
 		perm &= 0700
@@ -43,16 +51,20 @@ func ParseDirAttributes(sourceName string) DirAttributes {
 		name = "." + strings.TrimPrefix(name, dotPrefix)
 	}
 	return DirAttributes{
-		Name: name,
-		Perm: perm,
+		Name:  name,
+		Exact: exact,
+		Perm:  perm,
 	}
 }
 
 // SourceName returns da's source name.
 func (da DirAttributes) SourceName() string {
 	sourceName := ""
+	if da.Exact {
+		sourceName += exactPrefix
+	}
 	if da.Perm&os.FileMode(077) == os.FileMode(0) {
-		sourceName = privatePrefix
+		sourceName += privatePrefix
 	}
 	if strings.HasPrefix(da.Name, ".") {
 		sourceName += dotPrefix + strings.TrimPrefix(da.Name, ".")
@@ -63,10 +75,11 @@ func (da DirAttributes) SourceName() string {
 }
 
 // newDir returns a new directory state.
-func newDir(sourceName string, targetName string, perm os.FileMode) *Dir {
+func newDir(sourceName string, targetName string, exact bool, perm os.FileMode) *Dir {
 	return &Dir{
 		sourceName: sourceName,
 		targetName: targetName,
+		Exact:      exact,
 		Perm:       perm,
 		Entries:    make(map[string]Entry),
 	}
@@ -100,6 +113,20 @@ func (d *Dir) Apply(fs vfs.FS, targetDir string, umask os.FileMode, mutator Muta
 			return err
 		}
 	}
+	if d.Exact {
+		infos, err := fs.ReadDir(targetPath)
+		if err != nil {
+			return err
+		}
+		for _, info := range infos {
+			name := info.Name()
+			if _, ok := d.Entries[name]; !ok {
+				if err := mutator.RemoveAll(filepath.Join(targetPath, name)); err != nil {
+					return err
+				}
+			}
+		}
+	}
 	return nil
 }
 
@@ -119,6 +146,7 @@ func (d *Dir) ConcreteValue(targetDir, sourceDir string, recursive bool) (interf
 		Type:       "dir",
 		SourcePath: filepath.Join(sourceDir, d.SourceName()),
 		TargetPath: filepath.Join(targetDir, d.TargetName()),
+		Exact:      d.Exact,
 		Perm:       int(d.Perm),
 		Entries:    entryConcreteValues,
 	}, nil
diff --git a/lib/chezmoi/target_state.go b/lib/chezmoi/target_state.go
index 98eb67f79..2e950c62b 100644
--- a/lib/chezmoi/target_state.go
+++ b/lib/chezmoi/target_state.go
@@ -17,6 +17,20 @@ import (
 	vfs "github.com/twpayne/go-vfs"
 )
 
+// An AddOptions contains options for TargetState.Add.
+type AddOptions struct {
+	Empty    bool
+	Exact    bool
+	Template bool
+}
+
+// An ImportTAROptions contains options for TargetState.ImportTAR.
+type ImportTAROptions struct {
+	DestinationDir  string
+	Exact           bool
+	StripComponents int
+}
+
 // A TargetState represents the root target state.
 type TargetState struct {
 	TargetDir string
@@ -40,7 +54,7 @@ func NewTargetState(targetDir string, umask os.FileMode, sourceDir string, data
 }
 
 // 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 {
+func (ts *TargetState) Add(fs vfs.FS, addOptions AddOptions, targetPath string, info os.FileInfo, mutator Mutator) error {
 	if !filepath.HasPrefix(targetPath, ts.TargetDir) {
 		return fmt.Errorf("%s: outside target directory", targetPath)
 	}
@@ -65,7 +79,7 @@ func (ts *TargetState) Add(fs vfs.FS, targetPath string, info os.FileInfo, addEm
 			return err
 		}
 		if parentEntry == nil {
-			if err := ts.Add(fs, filepath.Join(ts.TargetDir, parentDirName), nil, false, false, mutator); err != nil {
+			if err := ts.Add(fs, addOptions, filepath.Join(ts.TargetDir, parentDirName), nil, mutator); err != nil {
 				return err
 			}
 			parentEntry, err = ts.findEntry(parentDirName)
@@ -88,19 +102,19 @@ func (ts *TargetState) Add(fs vfs.FS, targetPath string, info os.FileInfo, addEm
 			return err
 		}
 		empty := len(infos) == 0
-		return ts.addDir(targetName, entries, parentDirSourceName, perm, empty, mutator)
+		return ts.addDir(targetName, entries, parentDirSourceName, addOptions.Exact, perm, empty, mutator)
 	case info.Mode().IsRegular():
-		if info.Size() == 0 && !addEmpty {
+		if info.Size() == 0 && !addOptions.Empty {
 			return nil
 		}
 		contents, err := fs.ReadFile(targetPath)
 		if err != nil {
 			return err
 		}
-		if addTemplate {
+		if addOptions.Template {
 			contents = autoTemplate(contents, ts.Data)
 		}
-		return ts.addFile(targetName, entries, parentDirSourceName, info, addTemplate, contents, mutator)
+		return ts.addFile(targetName, entries, parentDirSourceName, info, addOptions.Template, contents, mutator)
 	case info.Mode()&os.ModeType == os.ModeSymlink:
 		linkname, err := fs.Readlink(targetPath)
 		if err != nil {
@@ -194,7 +208,7 @@ func (ts *TargetState) Get(target string) (Entry, error) {
 }
 
 // ImportTAR imports a tar archive.
-func (ts *TargetState) ImportTAR(r *tar.Reader, destinationDir string, stripComponents int, mutator Mutator) error {
+func (ts *TargetState) ImportTAR(r *tar.Reader, importTAROptions ImportTAROptions, mutator Mutator) error {
 	for {
 		header, err := r.Next()
 		if err == io.EOF {
@@ -204,7 +218,7 @@ func (ts *TargetState) ImportTAR(r *tar.Reader, destinationDir string, stripComp
 		}
 		switch header.Typeflag {
 		case tar.TypeDir, tar.TypeReg, tar.TypeSymlink:
-			if err := ts.importHeader(r, header, destinationDir, stripComponents, mutator); err != nil {
+			if err := ts.importHeader(r, importTAROptions, header, mutator); err != nil {
 				return err
 			}
 		case tar.TypeXGlobalHeader:
@@ -235,23 +249,24 @@ func (ts *TargetState) Populate(fs vfs.FS) error {
 		switch {
 		case info.Mode().IsDir():
 			components := splitPathList(relPath)
-			dirNames, perms := parseDirNameComponents(components)
-			targetName := filepath.Join(dirNames...)
-			entries, err := ts.findEntries(dirNames[:len(dirNames)-1])
+			das := parseDirNameComponents(components)
+			dns := dirNames(das)
+			targetName := filepath.Join(dns...)
+			entries, err := ts.findEntries(dns[:len(dns)-1])
 			if err != nil {
 				return err
 			}
-			dirName := dirNames[len(dirNames)-1]
-			perm := perms[len(perms)-1]
-			entries[dirName] = newDir(relPath, targetName, perm)
+			da := das[len(das)-1]
+			entries[da.Name] = newDir(relPath, targetName, da.Exact, da.Perm)
 		case info.Mode().IsRegular():
 			psfp := parseSourceFilePath(relPath)
-			entries, err := ts.findEntries(psfp.dirNames)
+			dns := dirNames(psfp.dirAttributes)
+			entries, err := ts.findEntries(dns)
 			if err != nil {
 				return err
 			}
 
-			targetName := filepath.Join(append(psfp.dirNames, psfp.Name)...)
+			targetName := filepath.Join(append(dns, psfp.Name)...)
 			var entry Entry
 			switch psfp.Mode & os.ModeType {
 			case 0:
@@ -299,7 +314,7 @@ func (ts *TargetState) Populate(fs vfs.FS) error {
 	})
 }
 
-func (ts *TargetState) addDir(targetName string, entries map[string]Entry, parentDirSourceName string, perm os.FileMode, empty bool, mutator Mutator) error {
+func (ts *TargetState) addDir(targetName string, entries map[string]Entry, parentDirSourceName string, exact bool, perm os.FileMode, empty bool, mutator Mutator) error {
 	name := filepath.Base(targetName)
 	var existingDir *Dir
 	if entry, ok := entries[name]; ok {
@@ -309,19 +324,23 @@ func (ts *TargetState) addDir(targetName string, entries map[string]Entry, paren
 		}
 	}
 	sourceName := DirAttributes{
-		Name: name,
-		Perm: perm,
+		Name:  name,
+		Exact: exact,
+		Perm:  perm,
 	}.SourceName()
 	if parentDirSourceName != "" {
 		sourceName = filepath.Join(parentDirSourceName, sourceName)
 	}
-	dir := newDir(sourceName, targetName, perm)
+	dir := newDir(sourceName, targetName, exact, 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))
 	}
+	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.
@@ -331,7 +350,7 @@ func (ts *TargetState) addDir(targetName string, entries map[string]Entry, paren
 		}
 	}
 	entries[name] = dir
-	return mutator.Mkdir(filepath.Join(ts.SourceDir, sourceName), 0777&^ts.Umask)
+	return nil
 }
 
 func (ts *TargetState) addFile(targetName string, entries map[string]Entry, parentDirSourceName string, info os.FileInfo, template bool, contents []byte, mutator Mutator) error {
@@ -477,13 +496,13 @@ func (ts *TargetState) findEntry(name string) (Entry, error) {
 	return entries[names[len(names)-1]], nil
 }
 
-func (ts *TargetState) importHeader(r io.Reader, header *tar.Header, destinationDir string, stripComponents int, mutator Mutator) error {
+func (ts *TargetState) importHeader(r io.Reader, importTAROptions ImportTAROptions, header *tar.Header, mutator Mutator) error {
 	targetPath := header.Name
-	if stripComponents > 0 {
-		targetPath = filepath.Join(strings.Split(targetPath, string(os.PathSeparator))[stripComponents:]...)
+	if importTAROptions.StripComponents > 0 {
+		targetPath = filepath.Join(strings.Split(targetPath, string(os.PathSeparator))[importTAROptions.StripComponents:]...)
 	}
-	if destinationDir != "" {
-		targetPath = filepath.Join(destinationDir, targetPath)
+	if importTAROptions.DestinationDir != "" {
+		targetPath = filepath.Join(importTAROptions.DestinationDir, targetPath)
 	} else {
 		targetPath = filepath.Join(ts.TargetDir, targetPath)
 	}
@@ -509,7 +528,7 @@ func (ts *TargetState) importHeader(r io.Reader, header *tar.Header, destination
 	case tar.TypeDir:
 		perm := os.FileMode(header.Mode).Perm()
 		empty := false // FIXME don't assume directory is empty
-		return ts.addDir(targetName, entries, parentDirSourceName, perm, empty, mutator)
+		return ts.addDir(targetName, entries, parentDirSourceName, importTAROptions.Exact, perm, empty, mutator)
 	case tar.TypeReg:
 		info := header.FileInfo()
 		contents, err := ioutil.ReadAll(r)


Test patch:
diff --git a/cmd/add_test.go b/cmd/add_test.go
index 8cc2ef867..21b9da1ca 100644
--- a/cmd/add_test.go
+++ b/cmd/add_test.go
@@ -3,6 +3,7 @@ package cmd
 import (
 	"testing"
 
+	"github.com/twpayne/chezmoi/lib/chezmoi"
 	"github.com/twpayne/go-vfs/vfst"
 )
 
@@ -16,16 +17,16 @@ func TestAddCommand(t *testing.T) {
 	}{
 		{
 			name: "add_first_file",
-			args: []string{"/home/jenkins/.bashrc"},
+			args: []string{"/home/user/.bashrc"},
 			root: map[string]interface{}{
-				"/home/jenkins/.bashrc": "foo",
+				"/home/user/.bashrc": "foo",
 			},
 			tests: []vfst.Test{
-				vfst.TestPath("/home/jenkins/.chezmoi",
+				vfst.TestPath("/home/user/.chezmoi",
 					vfst.TestIsDir,
 					vfst.TestModePerm(0700),
 				),
-				vfst.TestPath("/home/jenkins/.chezmoi/dot_bashrc",
+				vfst.TestPath("/home/user/.chezmoi/dot_bashrc",
 					vfst.TestModeIsRegular,
 					vfst.TestContentsString("foo"),
 				),
@@ -33,17 +34,19 @@ func TestAddCommand(t *testing.T) {
 		},
 		{
 			name: "add_template",
-			args: []string{"/home/jenkins/.gitconfig"},
+			args: []string{"/home/user/.gitconfig"},
 			add: addCommandConfig{
-				template: true,
+				options: chezmoi.AddOptions{
+					Template: true,
+				},
 			},
 			root: map[string]interface{}{
-				"/home/jenkins":            &vfst.Dir{Perm: 0755},
-				"/home/jenkins/.chezmoi":   &vfst.Dir{Perm: 0700},
-				"/home/jenkins/.gitconfig": "[user]\n\tname = John Smith\n\temail = john.smith@company.com\n",
+				"/home/user":            &vfst.Dir{Perm: 0755},
+				"/home/user/.chezmoi":   &vfst.Dir{Perm: 0700},
+				"/home/user/.gitconfig": "[user]\n\tname = John Smith\n\temail = john.smith@company.com\n",
 			},
 			tests: []vfst.Test{
-				vfst.TestPath("/home/jenkins/.chezmoi/dot_gitconfig.tmpl",
+				vfst.TestPath("/home/user/.chezmoi/dot_gitconfig.tmpl",
 					vfst.TestModeIsRegular,
 					vfst.TestContentsString("[user]\n\tname = {{ .name }}\n\temail = {{ .email }}\n"),
 				),
@@ -51,17 +54,17 @@ func TestAddCommand(t *testing.T) {
 		},
 		{
 			name: "add_recursive",
-			args: []string{"/home/jenkins/.config"},
+			args: []string{"/home/user/.config"},
 			add: addCommandConfig{
 				recursive: true,
 			},
 			root: map[string]interface{}{
-				"/home/jenkins":                             &vfst.Dir{Perm: 0755},
-				"/home/jenkins/.chezmoi":                    &vfst.Dir{Perm: 0700},
-				"/home/jenkins/.config/micro/settings.json": "{}",
+				"/home/user":                             &vfst.Dir{Perm: 0755},
+				"/home/user/.chezmoi":                    &vfst.Dir{Perm: 0700},
+				"/home/user/.config/micro/settings.json": "{}",
 			},
 			tests: []vfst.Test{
-				vfst.TestPath("/home/jenkins/.chezmoi/dot_config/micro/settings.json",
+				vfst.TestPath("/home/user/.chezmoi/dot_config/micro/settings.json",
 					vfst.TestModeIsRegular,
 					vfst.TestContentsString("{}"),
 				),
@@ -69,32 +72,79 @@ func TestAddCommand(t *testing.T) {
 		},
 		{
 			name: "add_nested_directory",
-			args: []string{"/home/jenkins/.config/micro/settings.json"},
+			args: []string{"/home/user/.config/micro/settings.json"},
 			root: map[string]interface{}{
-				"/home/jenkins":                             &vfst.Dir{Perm: 0755},
-				"/home/jenkins/.chezmoi":                    &vfst.Dir{Perm: 0700},
-				"/home/jenkins/.config/micro/settings.json": "{}",
+				"/home/user":                             &vfst.Dir{Perm: 0755},
+				"/home/user/.chezmoi":                    &vfst.Dir{Perm: 0700},
+				"/home/user/.config/micro/settings.json": "{}",
 			},
 			tests: []vfst.Test{
-				vfst.TestPath("/home/jenkins/.chezmoi/dot_config/micro/settings.json",
+				vfst.TestPath("/home/user/.chezmoi/dot_config/micro/settings.json",
 					vfst.TestModeIsRegular,
 					vfst.TestContentsString("{}"),
 				),
 			},
 		},
+		{
+			name: "add_exact_dir",
+			args: []string{"/home/user/dir"},
+			add: addCommandConfig{
+				options: chezmoi.AddOptions{
+					Exact: true,
+				},
+			},
+			root: map[string]interface{}{
+				"/home/user":          &vfst.Dir{Perm: 0755},
+				"/home/user/.chezmoi": &vfst.Dir{Perm: 0700},
+				"/home/user/dir":      &vfst.Dir{Perm: 0755},
+			},
+			tests: []vfst.Test{
+				vfst.TestPath("/home/user/.chezmoi/exact_dir",
+					vfst.TestIsDir,
+				),
+			},
+		},
+		{
+			name: "add_exact_dir_recursive",
+			args: []string{"/home/user/dir"},
+			add: addCommandConfig{
+				recursive: true,
+				options: chezmoi.AddOptions{
+					Exact: true,
+				},
+			},
+			root: map[string]interface{}{
+				"/home/user":          &vfst.Dir{Perm: 0755},
+				"/home/user/.chezmoi": &vfst.Dir{Perm: 0700},
+				"/home/user/dir": map[string]interface{}{
+					"foo": "bar",
+				},
+			},
+			tests: []vfst.Test{
+				vfst.TestPath("/home/user/.chezmoi/exact_dir",
+					vfst.TestIsDir,
+				),
+				vfst.TestPath("/home/user/.chezmoi/exact_dir/foo",
+					vfst.TestModeIsRegular,
+					vfst.TestContentsString("bar"),
+				),
+			},
+		},
 		{
 			name: "add_empty_file",
-			args: []string{"/home/jenkins/empty"},
+			args: []string{"/home/user/empty"},
 			add: addCommandConfig{
-				empty: true,
+				options: chezmoi.AddOptions{
+					Empty: true,
+				},
 			},
 			root: map[string]interface{}{
-				"/home/jenkins":          &vfst.Dir{Perm: 0755},
-				"/home/jenkins/.chezmoi": &vfst.Dir{Perm: 0700},
-				"/home/jenkins/empty":    "",
+				"/home/user":          &vfst.Dir{Perm: 0755},
+				"/home/user/.chezmoi": &vfst.Dir{Perm: 0700},
+				"/home/user/empty":    "",
 			},
 			tests: []vfst.Test{
-				vfst.TestPath("/home/jenkins/.chezmoi/empty_empty",
+				vfst.TestPath("/home/user/.chezmoi/empty_empty",
 					vfst.TestModeIsRegular,
 					vfst.TestContents(nil),
 				),
@@ -102,14 +152,14 @@ func TestAddCommand(t *testing.T) {
 		},
 		{
 			name: "add_symlink",
-			args: []string{"/home/jenkins/foo"},
+			args: []string{"/home/user/foo"},
 			root: map[string]interface{}{
-				"/home/jenkins":          &vfst.Dir{Perm: 0755},
-				"/home/jenkins/.chezmoi": &vfst.Dir{Perm: 0700},
-				"/home/jenkins/foo":      &vfst.Symlink{Target: "bar"},
+				"/home/user":          &vfst.Dir{Perm: 0755},
+				"/home/user/.chezmoi": &vfst.Dir{Perm: 0700},
+				"/home/user/foo":      &vfst.Symlink{Target: "bar"},
 			},
 			tests: []vfst.Test{
-				vfst.TestPath("/home/jenkins/.chezmoi/symlink_foo",
+				vfst.TestPath("/home/user/.chezmoi/symlink_foo",
 					vfst.TestModeIsRegular,
 					vfst.TestContentsString("bar"),
 				),
@@ -117,20 +167,20 @@ func TestAddCommand(t *testing.T) {
 		},
 		{
 			name: "add_symlink_in_dir_recursive",
-			args: []string{"/home/jenkins/foo"},
+			args: []string{"/home/user/foo"},
 			add: addCommandConfig{
 				recursive: true,
 			},
 			root: map[string]interface{}{
-				"/home/jenkins":          &vfst.Dir{Perm: 0755},
-				"/home/jenkins/.chezmoi": &vfst.Dir{Perm: 0700},
-				"/home/jenkins/foo/bar":  &vfst.Symlink{Target: "baz"},
+				"/home/user":          &vfst.Dir{Perm: 0755},
+				"/home/user/.chezmoi": &vfst.Dir{Perm: 0700},
+				"/home/user/foo/bar":  &vfst.Symlink{Target: "baz"},
 			},
 			tests: []vfst.Test{
-				vfst.TestPath("/home/jenkins/.chezmoi/foo",
+				vfst.TestPath("/home/user/.chezmoi/foo",
 					vfst.TestIsDir,
 				),
-				vfst.TestPath("/home/jenkins/.chezmoi/foo/symlink_bar",
+				vfst.TestPath("/home/user/.chezmoi/foo/symlink_bar",
 					vfst.TestModeIsRegular,
 					vfst.TestContentsString("baz"),
 				),
@@ -138,20 +188,20 @@ func TestAddCommand(t *testing.T) {
 		},
 		{
 			name: "add_symlink_with_parent_dir",
-			args: []string{"/home/jenkins/foo/bar/baz"},
+			args: []string{"/home/user/foo/bar/baz"},
 			root: map[string]interface{}{
-				"/home/jenkins":             &vfst.Dir{Perm: 0755},
-				"/home/jenkins/.chezmoi":    &vfst.Dir{Perm: 0700},
-				"/home/jenkins/foo/bar/baz": &vfst.Symlink{Target: "qux"},
+				"/home/user":             &vfst.Dir{Perm: 0755},
+				"/home/user/.chezmoi":    &vfst.Dir{Perm: 0700},
+				"/home/user/foo/bar/baz": &vfst.Symlink{Target: "qux"},
 			},
 			tests: []vfst.Test{
-				vfst.TestPath("/home/jenkins/.chezmoi/foo",
+				vfst.TestPath("/home/user/.chezmoi/foo",
 					vfst.TestIsDir,
 				),
-				vfst.TestPath("/home/jenkins/.chezmoi/foo/bar",
+				vfst.TestPath("/home/user/.chezmoi/foo/bar",
 					vfst.TestIsDir,
 				),
-				vfst.TestPath("/home/jenkins/.chezmoi/foo/bar/symlink_baz",
+				vfst.TestPath("/home/user/.chezmoi/foo/bar/symlink_baz",
 					vfst.TestModeIsRegular,
 					vfst.TestContentsString("qux"),
 				),
@@ -160,8 +210,8 @@ func TestAddCommand(t *testing.T) {
 	} {
 		t.Run(tc.name, func(t *testing.T) {
 			c := &Config{
-				SourceDir:        "/home/jenkins/.chezmoi",
-				TargetDir:        "/home/jenkins",
+				SourceDir:        "/home/user/.chezmoi",
+				TargetDir:        "/home/user",
 				Umask:            022,
 				DryRun:           false,
 				Verbose:          true,
@@ -187,39 +237,39 @@ func TestAddCommand(t *testing.T) {
 
 func TestAddAfterModification(t *testing.T) {
 	c := &Config{
-		SourceDir: "/home/jenkins/.chezmoi",
-		TargetDir: "/home/jenkins",
+		SourceDir: "/home/user/.chezmoi",
+		TargetDir: "/home/user",
 		Umask:     022,
 		DryRun:    false,
 		Verbose:   true,
 	}
 	fs, cleanup, err := vfst.NewTestFS(map[string]interface{}{
-		"/home/jenkins":          &vfst.Dir{Perm: 0755},
-		"/home/jenkins/.chezmoi": &vfst.Dir{Perm: 0700},
-		"/home/jenkins/.bashrc":  "# contents of .bashrc\n",
+		"/home/user":          &vfst.Dir{Perm: 0755},
+		"/home/user/.chezmoi": &vfst.Dir{Perm: 0700},
+		"/home/user/.bashrc":  "# contents of .bashrc\n",
 	})
 	defer cleanup()
 	if err != nil {
 		t.Fatalf("vfst.NewTestFS(_) == _, _, %v, want _, _, <nil>", err)
 	}
-	args := []string{"/home/jenkins/.bashrc"}
+	args := []string{"/home/user/.bashrc"}
 	if err := c.runAddCommand(fs, nil, args); err != nil {
 		t.Errorf("c.runAddCommand(fs, nil, %+v) == %v, want <nil>", args, err)
 	}
 	vfst.RunTests(t, fs, "",
-		vfst.TestPath("/home/jenkins/.chezmoi/dot_bashrc",
+		vfst.TestPath("/home/user/.chezmoi/dot_bashrc",
 			vfst.TestModeIsRegular,
 			vfst.TestContentsString("# contents of .bashrc\n"),
 		),
 	)
-	if err := fs.WriteFile("/home/jenkins/.bashrc", []byte("# new contents of .bashrc\n"), 0644); err != nil {
+	if err := fs.WriteFile("/home/user/.bashrc", []byte("# new contents of .bashrc\n"), 0644); err != nil {
 		t.Errorf("fs.WriteFile(...) == %v, want <nil>", err)
 	}
 	if err := c.runAddCommand(fs, nil, args); err != nil {
 		t.Errorf("c.runAddCommand(fs, nil, %+v) == %v, want <nil>", args, err)
 	}
 	vfst.RunTests(t, fs, "",
-		vfst.TestPath("/home/jenkins/.chezmoi/dot_bashrc",
+		vfst.TestPath("/home/user/.chezmoi/dot_bashrc",
 			vfst.TestModeIsRegular,
 			vfst.TestContentsString("# new contents of .bashrc\n"),
 		),
diff --git a/lib/chezmoi/dir_test.go b/lib/chezmoi/dir_test.go
index 973a7cab6..8e670ce29 100644
--- a/lib/chezmoi/dir_test.go
+++ b/lib/chezmoi/dir_test.go
@@ -32,6 +32,14 @@ func TestDirAttributes(t *testing.T) {
 				Perm: 0700,
 			},
 		},
+		{
+			sourceName: "exact_foo",
+			da: DirAttributes{
+				Name:  "foo",
+				Exact: true,
+				Perm:  0777,
+			},
+		},
 		{
 			sourceName: "private_dot_foo",
 			da: DirAttributes{
@@ -39,6 +47,14 @@ func TestDirAttributes(t *testing.T) {
 				Perm: 0700,
 			},
 		},
+		{
+			sourceName: "exact_private_dot_foo",
+			da: DirAttributes{
+				Name:  ".foo",
+				Exact: true,
+				Perm:  0700,
+			},
+		},
 	} {
 		t.Run(tc.sourceName, func(t *testing.T) {
 			gotDA := ParseDirAttributes(tc.sourceName)
diff --git a/lib/chezmoi/target_state_test.go b/lib/chezmoi/target_state_test.go
index 23247a6c4..682b2f811 100644
--- a/lib/chezmoi/target_state_test.go
+++ b/lib/chezmoi/target_state_test.go
@@ -20,7 +20,7 @@ func TestTargetStatePopulate(t *testing.T) {
 	}{
 		{
 			name: "simple_file",
-			root: map[string]string{
+			root: map[string]interface{}{
 				"/foo": "bar",
 			},
 			sourceDir: "/",
@@ -40,7 +40,7 @@ func TestTargetStatePopulate(t *testing.T) {
 		},
 		{
 			name: "dot_file",
-			root: map[string]string{
+			root: map[string]interface{}{
 				"/dot_foo": "bar",
 			},
 			sourceDir: "/",
@@ -60,7 +60,7 @@ func TestTargetStatePopulate(t *testing.T) {
 		},
 		{
 			name: "private_file",
-			root: map[string]string{
+			root: map[string]interface{}{
 				"/private_foo": "bar",
 			},
 			sourceDir: "/",
@@ -80,7 +80,7 @@ func TestTargetStatePopulate(t *testing.T) {
 		},
 		{
 			name: "file_in_subdir",
-			root: map[string]string{
+			root: map[string]interface{}{
 				"/foo/bar": "baz",
 			},
 			sourceDir: "/",
@@ -92,6 +92,7 @@ func TestTargetStatePopulate(t *testing.T) {
 					"foo": &Dir{
 						sourceName: "foo",
 						targetName: "foo",
+						Exact:      false,
 						Perm:       0777,
 						Entries: map[string]Entry{
 							"bar": &File{
@@ -107,7 +108,7 @@ func TestTargetStatePopulate(t *testing.T) {
 		},
 		{
 			name: "file_in_private_dot_subdir",
-			root: map[string]string{
+			root: map[string]interface{}{
 				"/private_dot_foo/bar": "baz",
 			},
 			sourceDir: "/",
@@ -119,6 +120,7 @@ func TestTargetStatePopulate(t *testing.T) {
 					".foo": &Dir{
 						sourceName: "private_dot_foo",
 						targetName: ".foo",
+						Exact:      false,
 						Perm:       0700,
 						Entries: map[string]Entry{
 							"bar": &File{
@@ -134,7 +136,7 @@ func TestTargetStatePopulate(t *testing.T) {
 		},
 		{
 			name: "template_dot_file",
-			root: map[string]string{
+			root: map[string]interface{}{
 				"/dot_gitconfig.tmpl": "[user]\n\temail = {{.Email}}\n",
 			},
 			sourceDir: "/",
@@ -159,6 +161,34 @@ func TestTargetStatePopulate(t *testing.T) {
 				},
 			},
 		},
+		{
+			name: "file_in_exact_dir",
+			root: map[string]interface{}{
+				"/exact_dir/foo": "bar",
+			},
+			sourceDir: "/",
+			want: &TargetState{
+				TargetDir: "/",
+				Umask:     0,
+				SourceDir: "/",
+				Entries: map[string]Entry{
+					"dir": &Dir{
+						sourceName: "exact_dir",
+						targetName: "dir",
+						Exact:      true,
+						Perm:       0777,
+						Entries: map[string]Entry{
+							"foo": &File{
+								sourceName: "exact_dir/foo",
+								targetName: "dir/foo",
+								Perm:       0666,
+								contents:   []byte("bar"),
+							},
+						},
+					},
+				},
+			},
+		},
 		{
 			name: "symlink",
 			root: map[string]interface{}{
@@ -258,15 +288,24 @@ func TestEndToEnd(t *testing.T) {
 		{
 			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",
+				"/home/user": map[string]interface{}{
+					".bashrc": "foo",
+					"dir": map[string]interface{}{
+						"foo": "foo",
+						"bar": "bar",
+					},
+					"replace_symlink": &vfst.Symlink{Target: "foo"},
+				},
+				"/home/user/.chezmoi": map[string]interface{}{
+					"dot_bashrc":              "bar",
+					".git/HEAD":               "HEAD",
+					"dot_hgrc.tmpl":           "[ui]\nusername = {{ .name }} <{{ .email }}>\n",
+					"empty.tmpl":              "{{ if false }}foo{{ end }}",
+					"empty_foo":               "",
+					"exact_dir/foo":           "foo",
+					"symlink_bar":             "empty",
+					"symlink_replace_symlink": "bar",
+				},
 			},
 			sourceDir: "/home/user/.chezmoi",
 			data: map[string]interface{}{
@@ -276,11 +315,29 @@ func TestEndToEnd(t *testing.T) {
 			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")),
+				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"),
+				),
+				vfst.TestPath("/home/user/dir/bar",
+					vfst.TestDoesNotExist,
+				),
 			},
 		},
 	} {