Repository: twpayne/chezmoi
Pull request #54 (2018-12-03 13:22:13)

Add chattr command


Patch:
diff --git a/cmd/chattr.go b/cmd/chattr.go
new file mode 100644
index 000000000..fd87c4f78
--- /dev/null
+++ b/cmd/chattr.go
@@ -0,0 +1,151 @@
+package cmd
+
+// FIXME add --apply flag
+// FIXME add --diff flag
+// FIXME add --prompt flag
+// FIXME add --recursive flag
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"sort"
+	"strings"
+
+	"github.com/spf13/cobra"
+	"github.com/twpayne/chezmoi/lib/chezmoi"
+	"github.com/twpayne/go-vfs"
+)
+
+var chattrCommand = &cobra.Command{
+	Use:   "chattr",
+	Args:  cobra.MinimumNArgs(2),
+	Short: "Change private, empty, executable, and template attributes of a target",
+	RunE:  makeRunE(config.runChattrCommand),
+}
+
+type boolModifier int
+
+type attributeModifiers struct {
+	empty      boolModifier
+	executable boolModifier
+	private    boolModifier
+	template   boolModifier
+}
+
+func init() {
+	rootCommand.AddCommand(chattrCommand)
+}
+
+func (c *Config) runChattrCommand(fs vfs.FS, cmd *cobra.Command, args []string) error {
+	ams, err := parseAttributeModifiers(args[0])
+	if err != nil {
+		return err
+	}
+	targetState, err := c.getTargetState(fs)
+	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)
+		}
+		oldSourceName := entry.SourceName()
+		var newSourceName string
+		switch entry := entry.(type) {
+		case *chezmoi.Dir:
+			psdn := chezmoi.ParseSourceDirName(oldSourceName)
+			if private := ams.private.modify(entry.Private()); private {
+				psdn.Perm &= 0700
+			}
+			newSourceName = psdn.SourceDirName()
+		case *chezmoi.File:
+			psfn := chezmoi.ParseSourceFileName(oldSourceName)
+			mode := os.FileMode(0666)
+			if executable := ams.executable.modify(entry.Executable()); executable {
+				mode |= 0111
+			}
+			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()
+		case *chezmoi.Symlink:
+			psfn := chezmoi.ParseSourceFileName(oldSourceName)
+			psfn.Template = ams.template.modify(entry.Template)
+			newSourceName = psfn.SourceFileName()
+		}
+		if newSourceName != oldSourceName {
+			renames[filepath.Join(targetState.SourceDir, oldSourceName)] = filepath.Join(targetState.SourceDir, newSourceName)
+		}
+	}
+
+	actuator := c.getDefaultActuator(fs)
+
+	// Sort oldpaths in reverse so we rename files before their parent
+	// directories.
+	var oldpaths []string
+	for oldpath := range renames {
+		oldpaths = append(oldpaths, oldpath)
+	}
+	sort.Sort(sort.Reverse(sort.StringSlice(oldpaths)))
+	for _, oldpath := range oldpaths {
+		if err := actuator.Rename(oldpath, renames[oldpath]); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func parseAttributeModifiers(s string) (*attributeModifiers, error) {
+	ams := &attributeModifiers{}
+	for _, attributeModifier := range strings.Split(s, ",") {
+		attributeModifier = strings.TrimSpace(attributeModifier)
+		if attributeModifier == "" {
+			continue
+		}
+		modifier := boolModifier(1)
+		if attributeModifier[0] == '-' {
+			modifier = boolModifier(-1)
+		}
+		attribute := attributeModifier
+		if attributeModifier[0] == '-' || attributeModifier[0] == '+' {
+			attribute = attributeModifier[1:]
+		}
+		switch attribute {
+		case "empty", "e":
+			ams.empty = modifier
+		case "executable", "x":
+			ams.executable = modifier
+		case "private", "p":
+			ams.private = modifier
+		case "template", "t":
+			ams.template = modifier
+		default:
+			return nil, fmt.Errorf("unknown attribute: %s", attribute)
+		}
+	}
+	return ams, nil
+}
+
+func (bm boolModifier) modify(x bool) bool {
+	switch {
+	case bm < 0:
+		return false
+	case bm > 0:
+		return true
+	default:
+		return x
+	}
+}
diff --git a/go.mod b/go.mod
index 5496bbc93..d4d1a98b4 100644
--- a/go.mod
+++ b/go.mod
@@ -14,7 +14,7 @@ require (
 	github.com/spf13/viper v1.2.1
 	github.com/stretchr/objx v0.1.1 // indirect
 	github.com/stretchr/testify v1.2.2 // indirect
-	github.com/twpayne/go-vfs v0.1.1
+	github.com/twpayne/go-vfs v0.1.3
 	github.com/zalando/go-keyring v0.0.0-20180221093347-6d81c293b3fb
 	golang.org/x/crypto v0.0.0-20181127143415-eb0de9b17e85
 )
diff --git a/go.sum b/go.sum
index a3b828d70..db7170fb1 100644
--- a/go.sum
+++ b/go.sum
@@ -42,8 +42,8 @@ github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A=
 github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=
 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
-github.com/twpayne/go-vfs v0.1.1 h1:5HFYyah2qxr+b2SDlF6ExZScSgjMvAzhjrnwxKLhI34=
-github.com/twpayne/go-vfs v0.1.1/go.mod h1:OIXA6zWkcn7Jk46XT7ceYqBMeIkfzJ8WOBhGJM0W4y8=
+github.com/twpayne/go-vfs v0.1.3 h1:xyBrYwrvm3GfQ92iwJmr1F0yXI+4Pz8J50IwHfq79Bg=
+github.com/twpayne/go-vfs v0.1.3/go.mod h1:OIXA6zWkcn7Jk46XT7ceYqBMeIkfzJ8WOBhGJM0W4y8=
 github.com/zalando/go-keyring v0.0.0-20180221093347-6d81c293b3fb h1:tXbazu9ZlecQbyCczvA22mWj+lw/36Bdwxapk8v7e7s=
 github.com/zalando/go-keyring v0.0.0-20180221093347-6d81c293b3fb/go.mod h1:XlXBIfkGawHNVOHlenOaBW7zlfCh8LovwjOgjamYnkQ=
 golang.org/x/crypto v0.0.0-20181127143415-eb0de9b17e85 h1:et7+NAX3lLIk5qUCTA9QelBjGE/NkhzYw/mhnr0s7nI=
diff --git a/lib/chezmoi/actuator.go b/lib/chezmoi/actuator.go
index 1e4355d9c..5fa5fc6d9 100644
--- a/lib/chezmoi/actuator.go
+++ b/lib/chezmoi/actuator.go
@@ -7,6 +7,7 @@ type Actuator interface {
 	Chmod(name string, mode os.FileMode) error
 	Mkdir(name string, perm os.FileMode) error
 	RemoveAll(name string) error
+	Rename(oldpath, newpath string) error
 	WriteFile(filename string, data []byte, perm os.FileMode, currData []byte) error
 	WriteSymlink(oldname, newname string) error
 }
diff --git a/lib/chezmoi/any_actuator.go b/lib/chezmoi/any_actuator.go
index 45e6bd19f..42ba70a9c 100644
--- a/lib/chezmoi/any_actuator.go
+++ b/lib/chezmoi/any_actuator.go
@@ -40,6 +40,12 @@ func (a *AnyActuator) RemoveAll(name string) error {
 	return a.a.RemoveAll(name)
 }
 
+// Rename implements Actuator.Rename.
+func (a *AnyActuator) Rename(oldpath, newpath string) error {
+	a.actuated = true
+	return a.a.Rename(oldpath, newpath)
+}
+
 // WriteFile implements Actuator.WriteFile.
 func (a *AnyActuator) WriteFile(name string, data []byte, perm os.FileMode, currData []byte) error {
 	a.actuated = true
diff --git a/lib/chezmoi/chezmoi.go b/lib/chezmoi/chezmoi.go
index 65cc04a35..78ffc9071 100644
--- a/lib/chezmoi/chezmoi.go
+++ b/lib/chezmoi/chezmoi.go
@@ -48,6 +48,7 @@ type File struct {
 	sourceName       string
 	Empty            bool
 	Perm             os.FileMode
+	Template         bool
 	contents         []byte
 	contentsErr      error
 	evaluateContents func() ([]byte, error)
@@ -63,6 +64,7 @@ type Dir struct {
 // A Symlink represents the target state of a symlink.
 type Symlink struct {
 	sourceName     string
+	Template       bool
 	target         string
 	targetErr      error
 	evaluateTarget func() (string, error)
@@ -78,20 +80,22 @@ type TargetState struct {
 	Entries   map[string]Entry
 }
 
-type parsedSourceDirName struct {
-	dirName string
-	perm    os.FileMode
+// ParsedSourceDirName is a parsed source dir name.
+type ParsedSourceDirName struct {
+	DirName string
+	Perm    os.FileMode
 }
 
-type parsedSourceFileName struct {
-	fileName string
-	mode     os.FileMode
-	empty    bool
-	template bool
+// A ParsedSourceFileName is a parsed source file name.
+type ParsedSourceFileName struct {
+	FileName string
+	Mode     os.FileMode
+	Empty    bool
+	Template bool
 }
 
 type parsedSourceFilePath struct {
-	parsedSourceFileName
+	ParsedSourceFileName
 	dirNames []string
 }
 
@@ -169,6 +173,11 @@ func (d *Dir) evaluate() error {
 	return nil
 }
 
+// Private returns true if d is private.
+func (d *Dir) Private() bool {
+	return d.Perm&os.ModePerm&077 == 0
+}
+
 // SourceName implements Entry.SourceName.
 func (d *Dir) SourceName() string {
 	return d.sourceName
@@ -255,6 +264,16 @@ func (f *File) Contents() ([]byte, error) {
 	return f.contents, f.contentsErr
 }
 
+// Executable returns true is f is executable.
+func (f *File) Executable() bool {
+	return f.Perm&0111 != 0
+}
+
+// Private returns true if f is private.
+func (f *File) Private() bool {
+	return f.Perm&os.ModePerm&077 == 0
+}
+
 // SourceName implements Entry.SourceName.
 func (f *File) SourceName() string {
 	return f.sourceName
@@ -385,11 +404,11 @@ func (ts *TargetState) Add(fs vfs.FS, target string, info os.FileInfo, addEmpty,
 		if info.Size() == 0 && !addEmpty {
 			return nil
 		}
-		sourceName := parsedSourceFileName{
-			fileName: name,
-			mode:     info.Mode() & os.ModePerm,
-			empty:    info.Size() == 0,
-			template: addTemplate,
+		sourceName := ParsedSourceFileName{
+			FileName: name,
+			Mode:     info.Mode() & os.ModePerm,
+			Empty:    info.Size() == 0,
+			Template: addTemplate,
 		}.SourceFileName()
 		if dirSourceName != "" {
 			sourceName = filepath.Join(dirSourceName, sourceName)
@@ -408,6 +427,7 @@ func (ts *TargetState) Add(fs vfs.FS, target string, info os.FileInfo, addEmpty,
 			sourceName: sourceName,
 			Empty:      len(data) == 0,
 			Perm:       info.Mode() & os.ModePerm,
+			Template:   addTemplate,
 			contents:   data,
 		}
 	case info.Mode().IsDir():
@@ -417,9 +437,9 @@ func (ts *TargetState) Add(fs vfs.FS, target string, info os.FileInfo, addEmpty,
 			}
 			return nil // entry already exists
 		}
-		sourceName := parsedSourceDirName{
-			dirName: name,
-			perm:    info.Mode() & os.ModePerm,
+		sourceName := ParsedSourceDirName{
+			DirName: name,
+			Perm:    info.Mode() & os.ModePerm,
 		}.SourceDirName()
 		if dirSourceName != "" {
 			sourceName = filepath.Join(dirSourceName, sourceName)
@@ -447,9 +467,9 @@ func (ts *TargetState) Add(fs vfs.FS, target string, info os.FileInfo, addEmpty,
 			}
 			return nil // entry already exists
 		}
-		sourceName := parsedSourceFileName{
-			fileName: name,
-			mode:     os.ModeSymlink,
+		sourceName := ParsedSourceFileName{
+			FileName: name,
+			Mode:     os.ModeSymlink,
 		}.SourceFileName()
 		if dirSourceName != "" {
 			sourceName = filepath.Join(dirSourceName, sourceName)
@@ -581,20 +601,21 @@ func (ts *TargetState) Populate(fs vfs.FS) error {
 				return err
 			}
 			var entry Entry
-			switch psfp.mode & os.ModeType {
+			switch psfp.Mode & os.ModeType {
 			case 0:
 				evaluateContents := func() ([]byte, error) {
 					return fs.ReadFile(path)
 				}
-				if psfp.template {
+				if psfp.Template {
 					evaluateContents = func() ([]byte, error) {
 						return ts.executeTemplate(fs, path)
 					}
 				}
 				entry = &File{
 					sourceName:       relPath,
-					Empty:            psfp.empty,
-					Perm:             psfp.mode & os.ModePerm,
+					Empty:            psfp.Empty,
+					Perm:             psfp.Mode & os.ModePerm,
+					Template:         psfp.Template,
 					evaluateContents: evaluateContents,
 				}
 			case os.ModeSymlink:
@@ -602,7 +623,7 @@ func (ts *TargetState) Populate(fs vfs.FS) error {
 					data, err := fs.ReadFile(path)
 					return string(data), err
 				}
-				if psfp.template {
+				if psfp.Template {
 					evaluateTarget = func() (string, error) {
 						data, err := ts.executeTemplate(fs, path)
 						return string(data), err
@@ -610,12 +631,13 @@ func (ts *TargetState) Populate(fs vfs.FS) error {
 				}
 				entry = &Symlink{
 					sourceName:     relPath,
+					Template:       psfp.Template,
 					evaluateTarget: evaluateTarget,
 				}
 			default:
-				return fmt.Errorf("%v: unsupported mode: %d", path, psfp.mode&os.ModeType)
+				return fmt.Errorf("%v: unsupported mode: %d", path, psfp.Mode&os.ModeType)
 			}
-			entries[psfp.fileName] = entry
+			entries[psfp.FileName] = entry
 		case info.Mode().IsDir():
 			components := splitPathList(relPath)
 			dirNames, perms := parseDirNameComponents(components)
@@ -692,8 +714,8 @@ func ReturnTemplateFuncError(err error) {
 	})
 }
 
-// parseSourceDirName parses a single directory name.
-func parseSourceDirName(dirName string) parsedSourceDirName {
+// 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)
@@ -702,28 +724,28 @@ func parseSourceDirName(dirName string) parsedSourceDirName {
 	if strings.HasPrefix(dirName, dotPrefix) {
 		dirName = "." + strings.TrimPrefix(dirName, dotPrefix)
 	}
-	return parsedSourceDirName{
-		dirName: dirName,
-		perm:    perm,
+	return ParsedSourceDirName{
+		DirName: dirName,
+		Perm:    perm,
 	}
 }
 
 // SourceDirName returns psdn's source dir name.
-func (psdn parsedSourceDirName) SourceDirName() string {
+func (psdn ParsedSourceDirName) SourceDirName() string {
 	sourceDirName := ""
-	if psdn.perm&os.FileMode(077) == os.FileMode(0) {
+	if psdn.Perm&os.FileMode(077) == os.FileMode(0) {
 		sourceDirName = privatePrefix
 	}
-	if strings.HasPrefix(psdn.dirName, ".") {
-		sourceDirName += dotPrefix + strings.TrimPrefix(psdn.dirName, ".")
+	if strings.HasPrefix(psdn.DirName, ".") {
+		sourceDirName += dotPrefix + strings.TrimPrefix(psdn.DirName, ".")
 	} else {
-		sourceDirName += psdn.dirName
+		sourceDirName += psdn.DirName
 	}
 	return sourceDirName
 }
 
-// parseSourceFileName parses a source file name.
-func parseSourceFileName(fileName string) parsedSourceFileName {
+// ParseSourceFileName parses a source file name.
+func ParseSourceFileName(fileName string) ParsedSourceFileName {
 	mode := os.FileMode(0666)
 	empty := false
 	template := false
@@ -755,26 +777,26 @@ func parseSourceFileName(fileName string) parsedSourceFileName {
 		fileName = strings.TrimSuffix(fileName, templateSuffix)
 		template = true
 	}
-	return parsedSourceFileName{
-		fileName: fileName,
-		mode:     mode,
-		empty:    empty,
-		template: template,
+	return ParsedSourceFileName{
+		FileName: fileName,
+		Mode:     mode,
+		Empty:    empty,
+		Template: template,
 	}
 }
 
 // SourceFileName returns psfn's source file name.
-func (psfn parsedSourceFileName) SourceFileName() string {
+func (psfn ParsedSourceFileName) SourceFileName() string {
 	fileName := ""
-	switch psfn.mode & os.ModeType {
+	switch psfn.Mode & os.ModeType {
 	case 0:
-		if psfn.mode&os.ModePerm&os.FileMode(077) == os.FileMode(0) {
+		if psfn.Mode&os.ModePerm&os.FileMode(077) == os.FileMode(0) {
 			fileName = privatePrefix
 		}
-		if psfn.empty {
+		if psfn.Empty {
 			fileName += emptyPrefix
 		}
-		if psfn.mode&os.ModePerm&os.FileMode(0111) != os.FileMode(0) {
+		if psfn.Mode&os.ModePerm&os.FileMode(0111) != os.FileMode(0) {
 			fileName += executablePrefix
 		}
 	case os.ModeSymlink:
@@ -782,12 +804,12 @@ func (psfn parsedSourceFileName) SourceFileName() string {
 	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, ".")
+	if strings.HasPrefix(psfn.FileName, ".") {
+		fileName += dotPrefix + strings.TrimPrefix(psfn.FileName, ".")
 	} else {
-		fileName += psfn.fileName
+		fileName += psfn.FileName
 	}
-	if psfn.template {
+	if psfn.Template {
 		fileName += templateSuffix
 	}
 	return fileName
@@ -799,9 +821,9 @@ 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)
+		psdn := ParseSourceDirName(component)
+		dirNames = append(dirNames, psdn.DirName)
+		perms = append(perms, psdn.Perm)
 	}
 	return dirNames, perms
 }
@@ -810,9 +832,9 @@ 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])
+	psfn := ParseSourceFileName(components[len(components)-1])
 	return parsedSourceFilePath{
-		parsedSourceFileName: psfn,
+		ParsedSourceFileName: psfn,
 		dirNames:             dirNames,
 	}
 }
diff --git a/lib/chezmoi/logging_actuator.go b/lib/chezmoi/logging_actuator.go
index 7de3e7b7a..f0c9951ee 100644
--- a/lib/chezmoi/logging_actuator.go
+++ b/lib/chezmoi/logging_actuator.go
@@ -58,6 +58,18 @@ func (a *LoggingActuator) RemoveAll(name string) error {
 	return err
 }
 
+// Rename implements Actuator.Rename.
+func (a *LoggingActuator) Rename(oldpath, newpath string) error {
+	action := fmt.Sprintf("mv %s %s", oldpath, newpath)
+	err := a.a.Rename(oldpath, newpath)
+	if err == nil {
+		fmt.Fprintln(a.w, action)
+	} else {
+		fmt.Fprintf(a.w, "%s: %v\n", action, err)
+	}
+	return err
+}
+
 // WriteFile implements Actuator.WriteFile.
 func (a *LoggingActuator) WriteFile(name string, data []byte, perm os.FileMode, currData []byte) error {
 	action := fmt.Sprintf("install -m %o /dev/null %s", perm, name)
diff --git a/lib/chezmoi/null_actuator.go b/lib/chezmoi/null_actuator.go
index 5c03b1e4c..55c186812 100644
--- a/lib/chezmoi/null_actuator.go
+++ b/lib/chezmoi/null_actuator.go
@@ -25,6 +25,11 @@ func (a *NullActuator) RemoveAll(string) error {
 	return nil
 }
 
+// Rename implements Actuator.Rename.
+func (a *NullActuator) Rename(string, string) error {
+	return nil
+}
+
 // WriteFile implements Actuator.WriteFile.
 func (a *NullActuator) WriteFile(string, []byte, os.FileMode, []byte) error {
 	return nil


Test patch:
diff --git a/cmd/chattr_test.go b/cmd/chattr_test.go
new file mode 100644
index 000000000..d835f863a
--- /dev/null
+++ b/cmd/chattr_test.go
@@ -0,0 +1,55 @@
+package cmd
+
+import (
+	"reflect"
+	"testing"
+)
+
+func TestParseAttributeModifiers(t *testing.T) {
+	for _, tc := range []struct {
+		s       string
+		want    *attributeModifiers
+		wantErr bool
+	}{
+		{s: "empty", want: &attributeModifiers{empty: 1}},
+		{s: "+empty", want: &attributeModifiers{empty: 1}},
+		{s: "-empty", want: &attributeModifiers{empty: -1}},
+		{s: "e", want: &attributeModifiers{empty: 1}},
+		{s: "+e", want: &attributeModifiers{empty: 1}},
+		{s: "-e", want: &attributeModifiers{empty: -1}},
+		{s: "executable", want: &attributeModifiers{executable: 1}},
+		{s: "+executable", want: &attributeModifiers{executable: 1}},
+		{s: "-executable", want: &attributeModifiers{executable: -1}},
+		{s: "x", want: &attributeModifiers{executable: 1}},
+		{s: "+x", want: &attributeModifiers{executable: 1}},
+		{s: "-x", want: &attributeModifiers{executable: -1}},
+		{s: "private", want: &attributeModifiers{private: 1}},
+		{s: "+private", want: &attributeModifiers{private: 1}},
+		{s: "-private", want: &attributeModifiers{private: -1}},
+		{s: "p", want: &attributeModifiers{private: 1}},
+		{s: "+p", want: &attributeModifiers{private: 1}},
+		{s: "-p", want: &attributeModifiers{private: -1}},
+		{s: "template", want: &attributeModifiers{template: 1}},
+		{s: "+template", want: &attributeModifiers{template: 1}},
+		{s: "-template", want: &attributeModifiers{template: -1}},
+		{s: "t", want: &attributeModifiers{template: 1}},
+		{s: "+t", want: &attributeModifiers{template: 1}},
+		{s: "-t", want: &attributeModifiers{template: -1}},
+		{s: "empty,executable,private,template", want: &attributeModifiers{empty: 1, executable: 1, private: 1, template: 1}},
+		{s: "+empty,+executable,+private,+template", want: &attributeModifiers{empty: 1, executable: 1, private: 1, template: 1}},
+		{s: "-empty,-executable,-private,-template", want: &attributeModifiers{empty: -1, executable: -1, private: -1, template: -1}},
+		{s: "foo", wantErr: true},
+		{s: "empty,foo", wantErr: true},
+		{s: "empty,foo", wantErr: true},
+		{s: " empty , -private ", want: &attributeModifiers{empty: 1, private: -1}},
+		{s: "empty,,-private", want: &attributeModifiers{empty: 1, private: -1}},
+	} {
+		if got, gotErr := parseAttributeModifiers(tc.s); (gotErr != nil && !tc.wantErr) || (gotErr == nil && tc.wantErr) || !reflect.DeepEqual(got, tc.want) {
+			wantErrStr := "<nil>"
+			if tc.wantErr {
+				wantErrStr = "!" + wantErrStr
+			}
+			t.Errorf("parseAttributeModifiers(%q) == %+v, %v, want %+v, %s", tc.s, got, gotErr, tc.want, wantErrStr)
+		}
+	}
+}
diff --git a/lib/chezmoi/chezmoi_test.go b/lib/chezmoi/chezmoi_test.go
index f4407f341..c86d4b26e 100644
--- a/lib/chezmoi/chezmoi_test.go
+++ b/lib/chezmoi/chezmoi_test.go
@@ -13,41 +13,41 @@ import (
 func TestSourceDirName(t *testing.T) {
 	for _, tc := range []struct {
 		sourceDirName string
-		psdn          parsedSourceDirName
+		psdn          ParsedSourceDirName
 	}{
 		{
 			sourceDirName: "foo",
-			psdn: parsedSourceDirName{
-				dirName: "foo",
-				perm:    0777,
+			psdn: ParsedSourceDirName{
+				DirName: "foo",
+				Perm:    0777,
 			},
 		},
 		{
 			sourceDirName: "dot_foo",
-			psdn: parsedSourceDirName{
-				dirName: ".foo",
-				perm:    0777,
+			psdn: ParsedSourceDirName{
+				DirName: ".foo",
+				Perm:    0777,
 			},
 		},
 		{
 			sourceDirName: "private_foo",
-			psdn: parsedSourceDirName{
-				dirName: "foo",
-				perm:    0700,
+			psdn: ParsedSourceDirName{
+				DirName: "foo",
+				Perm:    0700,
 			},
 		},
 		{
 			sourceDirName: "private_dot_foo",
-			psdn: parsedSourceDirName{
-				dirName: ".foo",
-				perm:    0700,
+			psdn: ParsedSourceDirName{
+				DirName: ".foo",
+				Perm:    0700,
 			},
 		},
 	} {
 		t.Run(tc.sourceDirName, func(t *testing.T) {
-			gotPSDN := parseSourceDirName(tc.sourceDirName)
+			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)
+				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)
@@ -59,107 +59,107 @@ func TestSourceDirName(t *testing.T) {
 func TestSourceFileName(t *testing.T) {
 	for _, tc := range []struct {
 		sourceFileName string
-		psfn           parsedSourceFileName
+		psfn           ParsedSourceFileName
 	}{
 		{
 			sourceFileName: "foo",
-			psfn: parsedSourceFileName{
-				fileName: "foo",
-				mode:     0666,
-				empty:    false,
-				template: false,
+			psfn: ParsedSourceFileName{
+				FileName: "foo",
+				Mode:     0666,
+				Empty:    false,
+				Template: false,
 			},
 		},
 		{
 			sourceFileName: "dot_foo",
-			psfn: parsedSourceFileName{
-				fileName: ".foo",
-				mode:     0666,
-				empty:    false,
-				template: false,
+			psfn: ParsedSourceFileName{
+				FileName: ".foo",
+				Mode:     0666,
+				Empty:    false,
+				Template: false,
 			},
 		},
 		{
 			sourceFileName: "private_foo",
-			psfn: parsedSourceFileName{
-				fileName: "foo",
-				mode:     0600,
-				empty:    false,
-				template: false,
+			psfn: ParsedSourceFileName{
+				FileName: "foo",
+				Mode:     0600,
+				Empty:    false,
+				Template: false,
 			},
 		},
 		{
 			sourceFileName: "private_dot_foo",
-			psfn: parsedSourceFileName{
-				fileName: ".foo",
-				mode:     0600,
-				empty:    false,
-				template: false,
+			psfn: ParsedSourceFileName{
+				FileName: ".foo",
+				Mode:     0600,
+				Empty:    false,
+				Template: false,
 			},
 		},
 		{
 			sourceFileName: "empty_foo",
-			psfn: parsedSourceFileName{
-				fileName: "foo",
-				mode:     0666,
-				empty:    true,
-				template: false,
+			psfn: ParsedSourceFileName{
+				FileName: "foo",
+				Mode:     0666,
+				Empty:    true,
+				Template: false,
 			},
 		},
 		{
 			sourceFileName: "executable_foo",
-			psfn: parsedSourceFileName{
-				fileName: "foo",
-				mode:     0777,
-				empty:    false,
-				template: false,
+			psfn: ParsedSourceFileName{
+				FileName: "foo",
+				Mode:     0777,
+				Empty:    false,
+				Template: false,
 			},
 		},
 		{
 			sourceFileName: "foo.tmpl",
-			psfn: parsedSourceFileName{
-				fileName: "foo",
-				mode:     0666,
-				empty:    false,
-				template: true,
+			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,
+			psfn: ParsedSourceFileName{
+				FileName: ".foo",
+				Mode:     0700,
+				Empty:    false,
+				Template: true,
 			},
 		},
 		{
 			sourceFileName: "symlink_foo",
-			psfn: parsedSourceFileName{
-				fileName: "foo",
-				mode:     os.ModeSymlink | 0666,
+			psfn: ParsedSourceFileName{
+				FileName: "foo",
+				Mode:     os.ModeSymlink | 0666,
 			},
 		},
 		{
 			sourceFileName: "symlink_dot_foo",
-			psfn: parsedSourceFileName{
-				fileName: ".foo",
-				mode:     os.ModeSymlink | 0666,
+			psfn: ParsedSourceFileName{
+				FileName: ".foo",
+				Mode:     os.ModeSymlink | 0666,
 			},
 		},
 		{
 			sourceFileName: "symlink_foo.tmpl",
-			psfn: parsedSourceFileName{
-				fileName: "foo",
-				mode:     os.ModeSymlink | 0666,
-				template: true,
+			psfn: ParsedSourceFileName{
+				FileName: "foo",
+				Mode:     os.ModeSymlink | 0666,
+				Template: true,
 			},
 		},
 	} {
 		t.Run(tc.sourceFileName, func(t *testing.T) {
-			gotPSFN := parseSourceFileName(tc.sourceFileName)
+			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)
+				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)
@@ -304,6 +304,7 @@ func TestTargetStatePopulate(t *testing.T) {
 					".gitconfig": &File{
 						sourceName: "dot_gitconfig.tmpl",
 						Perm:       0666,
+						Template:   true,
 						contents:   []byte("[user]\n\temail = user@example.com\n"),
 					},
 				},
@@ -364,6 +365,7 @@ func TestTargetStatePopulate(t *testing.T) {
 				Entries: map[string]Entry{
 					"foo": &Symlink{
 						sourceName: "symlink_foo.tmpl",
+						Template:   true,
 						target:     "bar-example.com",
 					},
 				},