Repository: twpayne/chezmoi
Pull request #177 (2019-01-28 01:36:16)

Refactor init command and add update command


Patch:
diff --git a/README.md b/README.md
index 00a167b56..b4e871fe6 100644
--- a/README.md
+++ b/README.md
@@ -471,13 +471,14 @@ You can encrypt your configuration and then only decrypt it when needed:
 
 `chezmoi` has some helper commands to assist managing your source directory
 with version control. The default version control system is `git` but you can
-change this by setting `sourceVCSCommand` in your
+change this by setting `sourceVCS.command` in your
 `~/.config/chezmoi/chezmoi.yaml` file, for example, if you want to use
 Mercurial:
 
-    sourceVCSCommand: hg
+    sourceVCS:
+      command: hg
 
-`chezmoi source` is then a shortcut to running `sourceVCSCommand` in your
+`chezmoi source` is then a shortcut to running `sourceVCS.command` in your
 `~/.local/share/chezmoi` directory. For example you can push the current branch
 with:
 
diff --git a/cmd/add.go b/cmd/add.go
index ccc3866a7..5a82ff056 100644
--- a/cmd/add.go
+++ b/cmd/add.go
@@ -41,21 +41,7 @@ func (c *Config) runAddCommand(fs vfs.FS, args []string) (err error) {
 		return err
 	}
 	mutator := c.getDefaultMutator(fs)
-	info, err := fs.Stat(c.SourceDir)
-	switch {
-	case err == nil && info.Mode().IsDir():
-		if info.Mode().Perm() != 0700 {
-			if err := mutator.Chmod(c.SourceDir, 0700); err != nil {
-				return err
-			}
-		}
-	case os.IsNotExist(err):
-		if err := mutator.Mkdir(c.SourceDir, 0700); err != nil {
-			return err
-		}
-	case err == nil:
-		return fmt.Errorf("%s: not a directory", c.SourceDir)
-	default:
+	if err := c.ensureSourceDirectory(fs, mutator); err != nil {
 		return err
 	}
 	destDirPrefix := ts.DestDir + "/"
diff --git a/cmd/config.go b/cmd/config.go
index 1f49d7f31..08832ea37 100644
--- a/cmd/config.go
+++ b/cmd/config.go
@@ -24,30 +24,37 @@ import (
 	yaml "gopkg.in/yaml.v2"
 )
 
+type sourceVCSConfig struct {
+	Command string
+	Init    interface{}
+	Pull    interface{}
+}
+
 // A Config represents a configuration.
 type Config struct {
-	configFile       string
-	SourceDir        string
-	DestDir          string
-	Umask            permValue
-	DryRun           bool
-	Verbose          bool
-	SourceVCSCommand string
-	Bitwarden        bitwardenCommandConfig
-	GenericSecret    genericSecretCommandConfig
-	LastPass         lastpassCommandConfig
-	OnePassword      onepasswordCommandConfig
-	Vault            vaultCommandConfig
-	Pass             passCommandConfig
-	Data             map[string]interface{}
-	templateFuncs    template.FuncMap
-	add              addCommandConfig
-	data             dataCommandConfig
-	dump             dumpCommandConfig
-	edit             editCommandConfig
-	init             initCommandConfig
-	_import          importCommandConfig
-	keyring          keyringCommandConfig
+	configFile    string
+	SourceDir     string
+	DestDir       string
+	Umask         permValue
+	DryRun        bool
+	Verbose       bool
+	SourceVCS     sourceVCSConfig
+	Bitwarden     bitwardenCommandConfig
+	GenericSecret genericSecretCommandConfig
+	LastPass      lastpassCommandConfig
+	OnePassword   onepasswordCommandConfig
+	Vault         vaultCommandConfig
+	Pass          passCommandConfig
+	Data          map[string]interface{}
+	templateFuncs template.FuncMap
+	add           addCommandConfig
+	data          dataCommandConfig
+	dump          dumpCommandConfig
+	edit          editCommandConfig
+	init          initCommandConfig
+	_import       importCommandConfig
+	keyring       keyringCommandConfig
+	update        updateCommandConfig
 }
 
 var (
@@ -100,6 +107,28 @@ func (c *Config) applyArgs(fs vfs.FS, args []string, mutator chezmoi.Mutator) er
 	return nil
 }
 
+func (c *Config) ensureSourceDirectory(fs vfs.FS, mutator chezmoi.Mutator) error {
+	info, err := fs.Stat(c.SourceDir)
+	switch {
+	case err == nil && info.IsDir():
+		if info.Mode().Perm()&os.FileMode(c.Umask) != 0700&^os.FileMode(c.Umask) {
+			if err := mutator.Chmod(c.SourceDir, 0700&^os.FileMode(c.Umask)); err != nil {
+				return err
+			}
+		}
+		return nil
+	case os.IsNotExist(err):
+		if err := mkdirAll(fs, mutator, filepath.Dir(c.SourceDir), 0777&^os.FileMode(c.Umask)); err != nil {
+			return err
+		}
+		return mutator.Mkdir(c.SourceDir, 0700&^os.FileMode(c.Umask))
+	case err == nil:
+		return fmt.Errorf("%s: not a directory", c.SourceDir)
+	default:
+		return err
+	}
+}
+
 func (c *Config) exec(argv []string) error {
 	path, err := exec.LookPath(argv[0])
 	if err != nil {
@@ -114,22 +143,6 @@ func (c *Config) exec(argv []string) error {
 	return syscall.Exec(path, argv, os.Environ())
 }
 
-// execCmd is like exec but without doing a syscall.
-func (c *Config) execCmd(name string, args ...string) error {
-	cmd := exec.Command(name, args...)
-	if c.Verbose {
-		fmt.Printf("exec %s %s\n", name, strings.Join(args, " "))
-		cmd.Stdout = os.Stdout
-		cmd.Stderr = os.Stdout
-	}
-
-	if c.DryRun {
-		return nil
-	}
-
-	return cmd.Run()
-}
-
 func (c *Config) execEditor(argv ...string) error {
 	return c.exec(append([]string{c.getEditor()}, argv...))
 }
@@ -195,21 +208,38 @@ func (c *Config) getTargetState(fs vfs.FS) (*chezmoi.TargetState, error) {
 	return ts, nil
 }
 
-func (c *Config) runEditor(argv ...string) error {
-	editor := c.getEditor()
+func (c *Config) getVCSInfo() (*vcsInfo, error) {
+	vcsInfo, ok := vcsInfos[filepath.Base(c.SourceVCS.Command)]
+	if !ok {
+		return nil, fmt.Errorf("%s: unsupported source VCS command", c.SourceVCS.Command)
+	}
+	return vcsInfo, nil
+}
+
+// run runs name argv... in dir.
+func (c *Config) run(dir, name string, argv ...string) error {
 	if c.Verbose {
-		fmt.Printf("%s %s\n", editor, strings.Join(argv, " "))
+		if dir == "" {
+			fmt.Printf("%s %s\n", name, strings.Join(argv, " "))
+		} else {
+			fmt.Printf("( cd %s && %s %s )\n", dir, name, strings.Join(argv, " "))
+		}
 	}
 	if c.DryRun {
 		return nil
 	}
-	cmd := exec.Command(editor, argv...)
+	cmd := exec.Command(name, argv...)
+	cmd.Dir = dir
 	cmd.Stdin = os.Stdin
 	cmd.Stdout = os.Stdout
-	cmd.Stderr = os.Stderr
+	cmd.Stderr = os.Stdout
 	return cmd.Run()
 }
 
+func (c *Config) runEditor(argv ...string) error {
+	return c.run("", c.getEditor(), argv...)
+}
+
 func getDefaultConfigFile(x *xdg.XDG, homeDir string) string {
 	// Search XDG config directories first.
 	for _, configDir := range x.ConfigDirs {
@@ -297,6 +327,28 @@ func makeRunE(runCommand func(vfs.FS, []string) error) func(*cobra.Command, []st
 	}
 }
 
+func mkdirAll(fs vfs.FS, mutator chezmoi.Mutator, path string, perm os.FileMode) error {
+	if parentDir := filepath.Dir(path); parentDir != "." {
+		info, err := fs.Stat(parentDir)
+		if err != nil && os.IsNotExist(err) {
+			if mkdirAllErr := mkdirAll(fs, mutator, parentDir, perm); mkdirAllErr != nil {
+				return mkdirAllErr
+			} else if err != nil {
+				return err
+			} else if err == nil && !info.IsDir() {
+				return fmt.Errorf("%s: not a directory", parentDir)
+			}
+		}
+	}
+	info, err := fs.Stat(path)
+	if err != nil && !os.IsNotExist(err) {
+		return err
+	} else if err == nil && info.IsDir() {
+		return nil
+	}
+	return mutator.Mkdir(path, perm)
+}
+
 func printErrorAndExit(err error) {
 	fmt.Printf("chezmoi: %v\n", err)
 	os.Exit(1)
diff --git a/cmd/doctor.go b/cmd/doctor.go
index 5f38ce8a6..d68a00dda 100644
--- a/cmd/doctor.go
+++ b/cmd/doctor.go
@@ -25,36 +25,6 @@ const (
 	errorPrefix   = "  ERROR: "
 )
 
-type vcsInfo struct {
-	versionArgs   []string
-	versionRegexp *regexp.Regexp
-}
-
-var (
-	vcsInfos = map[string]vcsInfo{
-		"bzr": vcsInfo{
-			versionArgs:   []string{"--version"},
-			versionRegexp: regexp.MustCompile(`^Bazaar (bzr) (\d+\.\d+\.\d+)`),
-		},
-		"cvs": vcsInfo{
-			versionArgs:   []string{"--version"},
-			versionRegexp: regexp.MustCompile(`^Concurrent Versions System \(CVS\) (\d+\.\d+\.\d+)`),
-		},
-		"git": vcsInfo{
-			versionArgs:   []string{"version"},
-			versionRegexp: regexp.MustCompile(`^git version (\d+\.\d+\.\d+)`),
-		},
-		"hg": vcsInfo{
-			versionArgs:   []string{"version"},
-			versionRegexp: regexp.MustCompile(`^Mercurial Distributed SCM \(version (\d+\.\d+\.\d+\))`),
-		},
-		"svn": vcsInfo{
-			versionArgs:   []string{"--version"},
-			versionRegexp: regexp.MustCompile(`^svn, version (\d+\.\d+\.\d+)`),
-		},
-	}
-)
-
 type doctorCheck interface {
 	Check() (bool, error)
 	Enabled() bool
@@ -81,6 +51,7 @@ type doctorBinaryCheck struct {
 type doctorDirectoryCheck struct {
 	name         string
 	path         string
+	err          error
 	dontWantPerm os.FileMode
 	info         os.FileInfo
 }
@@ -97,6 +68,22 @@ func init() {
 }
 
 func (c *Config) runDoctorCommandE(fs vfs.FS, args []string) error {
+	var vcsCommandCheck doctorCheck
+	if vcsInfo, err := c.getVCSInfo(); err == nil {
+		vcsCommandCheck = &doctorBinaryCheck{
+			name:          "source VCS command",
+			binaryName:    c.SourceVCS.Command,
+			versionArgs:   vcsInfo.versionArgs,
+			versionRegexp: vcsInfo.versionRegexp,
+		}
+	} else {
+		// FIXME print a warning that source VCS command is unsupported
+		vcsCommandCheck = &doctorBinaryCheck{
+			name:       "source VCS command",
+			binaryName: c.SourceVCS.Command,
+		}
+	}
+
 	allOK := true
 	for _, dc := range []doctorCheck{
 		&doctorDirectoryCheck{
@@ -117,12 +104,7 @@ func (c *Config) runDoctorCommandE(fs vfs.FS, args []string) error {
 			binaryName:  c.getEditor(),
 			mustSucceed: true,
 		},
-		&doctorBinaryCheck{
-			name:          "source VCS command",
-			binaryName:    c.SourceVCSCommand,
-			versionArgs:   vcsInfos[c.SourceVCSCommand].versionArgs,
-			versionRegexp: vcsInfos[c.SourceVCSCommand].versionRegexp,
-		},
+		vcsCommandCheck,
 		&doctorBinaryCheck{
 			name:          "1Password CLI",
 			binaryName:    c.OnePassword.Op,
@@ -252,12 +234,11 @@ func (c *doctorBinaryCheck) Result() string {
 }
 
 func (c *doctorDirectoryCheck) Check() (bool, error) {
-	var err error
-	c.info, err = os.Stat(c.path)
-	if err != nil && os.IsNotExist(err) {
+	c.info, c.err = os.Stat(c.path)
+	if c.err != nil && os.IsNotExist(c.err) {
 		return false, nil
-	} else if err != nil {
-		return false, err
+	} else if c.err != nil {
+		return false, c.err
 	}
 	if c.info.Mode()&os.ModePerm&c.dontWantPerm != 0 {
 		return false, nil
@@ -274,7 +255,14 @@ func (c *doctorDirectoryCheck) MustSucceed() bool {
 }
 
 func (c *doctorDirectoryCheck) Result() string {
-	return fmt.Sprintf("%s (%s, perm %03o)", c.path, c.name, c.info.Mode()&os.ModePerm)
+	switch {
+	case os.IsNotExist(c.err):
+		return fmt.Sprintf("%s: (%s, not found)", c.path, c.name)
+	case c.err != nil:
+		return fmt.Sprintf("%s: (%s, %v)", c.path, c.name, c.err)
+	default:
+		return fmt.Sprintf("%s (%s, perm %03o)", c.path, c.name, c.info.Mode()&os.ModePerm)
+	}
 }
 
 func (c *doctorFileCheck) Check() (bool, error) {
diff --git a/cmd/init.go b/cmd/init.go
index e1fc56a86..3a9bae508 100644
--- a/cmd/init.go
+++ b/cmd/init.go
@@ -2,17 +2,17 @@ package cmd
 
 import (
 	"fmt"
-	"github.com/spf13/cobra"
-	"github.com/twpayne/chezmoi/lib/chezmoi"
-	"github.com/twpayne/go-vfs"
-	"os"
 	"path/filepath"
+	"strings"
+
+	"github.com/spf13/cobra"
+	vfs "github.com/twpayne/go-vfs"
 )
 
 // initCmd represents the init command
 var initCommand = &cobra.Command{
-	Args:  cobra.ExactArgs(1),
-	Use:   "init <repo URL>",
+	Args:  cobra.MaximumNArgs(1),
+	Use:   "init [repo]",
 	Short: "Initial setup of the source directory then update the destination directory to match the target state",
 	Long: `Initial setup of the source directory then update the destination directory to match the target state.
 
@@ -22,7 +22,7 @@ and make sure that all directory permissions are correct.
 
 After your source directory was checked out and setup (e.g. git submodules) this
 command will automatically invoke the "apply" command to update the destination
-directory. You can use the --no-apply flag to prevent this from happening.
+directory. You can use the --apply=false flag to prevent this from happening.
 `,
 	Example: `
   # Checkout from github using the public HTTPS API
@@ -35,142 +35,74 @@ directory. You can use the --no-apply flag to prevent this from happening.
 }
 
 type initCommandConfig struct {
-	noApply bool
+	apply bool
 }
 
 func init() {
 	rootCommand.AddCommand(initCommand)
 
 	persistentFlags := initCommand.PersistentFlags()
-	persistentFlags.BoolVar(&config.init.noApply, "no-apply", false, "do not update destination directory")
+	persistentFlags.BoolVar(&config.init.apply, "apply", true, "update destination directory")
 }
 
 func (c *Config) runInitCommand(fs vfs.FS, args []string) error {
-	mutator := c.getDefaultMutator(fs)
-	err := c.initSourceParentDirs(fs, mutator)
+	vcsInfo, err := c.getVCSInfo()
 	if err != nil {
 		return err
 	}
 
-	err = c.checkoutSourceDir(fs, mutator, args[0])
-	if err != nil {
-		return err
-	}
+	mutator := c.getDefaultMutator(fs)
 
-	if config.init.noApply {
-		fmt.Println("Did not update target directory (--no-apply)")
-		return nil
+	if err := c.ensureSourceDirectory(fs, mutator); err != nil {
+		return err
 	}
 
-	fmt.Printf("Updating target directory %q\n", c.DestDir)
-	ts, err := c.getTargetState(fs)
-	return ts.Apply(fs, mutator)
-}
-
-func (c *Config) initSourceParentDirs(fs vfs.FS, mutator chezmoi.Mutator) error {
-	dirMode := os.FileMode(0700)
-	parts := splitPath(c.SourceDir)
-	parts = parts[:len(parts)-1] // all parent directories of the source directory
-
-	var createdDir string
-	for _, path := range parts {
-		_, err := fs.Stat(path)
-		switch {
-		case os.IsNotExist(err):
-			createdDir = path
-			err = mutator.Mkdir(path, dirMode)
-			if err != nil {
-				return err
+	switch len(args) {
+	case 0: // init
+		var initArgs []string
+		if c.SourceVCS.Init != nil {
+			switch v := c.SourceVCS.Init.(type) {
+			case string:
+				initArgs = strings.Split(v, " ")
+			case []string:
+				initArgs = v
+			default:
+				return fmt.Errorf("sourceVCS.init: cannot parse value")
 			}
-		case err != nil:
-			return err
+		} else {
+			initArgs = vcsInfo.initArgs
 		}
-	}
-
-	if createdDir != "" {
-		fmt.Printf("Created source directory %q with file mode 0%o (%v) \n", createdDir, dirMode, dirMode)
-	}
-
-	return nil
-}
-
-func (c *Config) checkoutSourceDir(fs vfs.FS, mutator chezmoi.Mutator, repo string) error {
-	fmt.Printf("Checking out dotfiles repository %q into %q\n", repo, c.SourceDir)
-
-	vcsCmd := c.SourceVCSCommand
-	if vcsCmd == "" {
-		vcsCmd = "git"
-	}
-
-	var args []string
-	switch vcsCmd {
-	case "git", "hg":
-		args = []string{"clone", repo, c.SourceDir}
-	case "test":
-		// While "test" isn't actually checking out anything, this command is
-		// used to unit test this function and potentially for debugging.
-		vcsCmd = "echo"
-		args = []string{"-n", "clone", repo, c.SourceDir}
-		err := mutator.Mkdir(c.SourceDir, 0777)
-		if err != nil {
+		if err := c.run(c.SourceDir, c.SourceVCS.Command, initArgs...); err != nil {
 			return err
 		}
-	default:
-		return fmt.Errorf("VCS command %q is not yet support in chezmoi init", c.SourceVCSCommand)
-	}
-
-	err := c.execCmd(vcsCmd, args...)
-	if err != nil {
-		return err
-	}
-
-	err = mutator.Chmod(c.SourceDir, 0700)
-	if err != nil {
-		return err
-	}
-
-	if info, err := fs.Stat(filepath.Join(c.SourceDir, ".gitmodules")); err == nil && !info.IsDir() {
-		return c.initSourceSubmodules()
-	}
-
-	return nil
-}
-
-func (c *Config) initSourceSubmodules() error {
-	fmt.Printf("Initializing git submodules in %q\n", c.SourceDir)
-
-	if c.Verbose {
-		fmt.Printf("cd %s\n", c.SourceDir)
-	}
-
-	if !c.DryRun {
-		if err := os.Chdir(c.SourceDir); err != nil {
+	case 1: // clone
+		if vcsInfo.cloneArgsFunc == nil {
+			return fmt.Errorf("%s: cloning not supported", c.SourceVCS.Command)
+		}
+		cloneArgs := vcsInfo.cloneArgsFunc(args[0], c.SourceDir)
+		if err := c.run("", c.SourceVCS.Command, cloneArgs...); err != nil {
 			return err
 		}
+		// FIXME this should be part of struct vcs
+		switch filepath.Base(c.SourceVCS.Command) {
+		case "git":
+			if _, err := fs.Stat(filepath.Join(c.SourceDir, ".gitmodules")); err == nil {
+				for _, args := range [][]string{
+					[]string{"submodule", "init"},
+					[]string{"submodule", "update"},
+				} {
+					if err := c.run(c.SourceDir, c.SourceVCS.Command, args...); err != nil {
+						return err
+					}
+				}
+			}
+		}
+		if c.init.apply {
+			if err := c.applyArgs(fs, nil, mutator); err != nil {
+				return err
+			}
+		}
 	}
 
-	err := c.execCmd("git", "submodule", "init")
-	if err != nil {
-		return err
-	}
-
-	return c.execCmd("git", "submodule", "update")
-}
-
-func splitPath(p string) []string {
-	dirs := []string{p}
-
-	dir := filepath.Dir(p)
-	for dir != string(filepath.Separator) {
-		dirs = append(dirs, dir)
-		dir = filepath.Dir(dir)
-	}
-
-	// reverse dirs slice (i.e. order by path length in ascending order)
-	for i := 0; i < len(dirs)/2; i++ {
-		j := len(dirs) - i - 1 // index from the end of dirs
-		dirs[i], dirs[j] = dirs[j], dirs[i]
-	}
-
-	return dirs
+	return nil
 }
diff --git a/cmd/root.go b/cmd/root.go
index cb0bf051d..402e858f0 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -13,8 +13,10 @@ import (
 
 var (
 	config = Config{
-		Umask:            permValue(getUmask()),
-		SourceVCSCommand: "git",
+		Umask: permValue(getUmask()),
+		SourceVCS: sourceVCSConfig{
+			Command: "git",
+		},
 	}
 	version = "dev"
 	commit  = "unknown"
@@ -23,7 +25,7 @@ var (
 
 var rootCommand = &cobra.Command{
 	Use:               "chezmoi",
-	Short:             "Manage your dotfiles securely across multiple machines",
+	Short:             "Manage your dotfiles across multiple machines, securely",
 	SilenceErrors:     true,
 	SilenceUsage:      true,
 	PersistentPreRunE: makeRunE(config.persistentPreRunRootE),
@@ -84,7 +86,7 @@ func Execute() {
 func (c *Config) persistentPreRunRootE(fs vfs.FS, args []string) error {
 	info, err := fs.Stat(c.SourceDir)
 	switch {
-	case err == nil && !info.Mode().IsDir():
+	case err == nil && !info.IsDir():
 		return fmt.Errorf("%s: not a directory", c.SourceDir)
 	case err == nil && info.Mode().Perm() != 0700:
 		fmt.Printf("%s: want permissions 0700, got 0%o\n", c.SourceDir, info.Mode().Perm())
diff --git a/cmd/source.go b/cmd/source.go
index 1fedbb512..fb80c16ee 100644
--- a/cmd/source.go
+++ b/cmd/source.go
@@ -27,5 +27,5 @@ func (c *Config) runSourceCommand(fs vfs.FS, args []string) error {
 			return err
 		}
 	}
-	return c.exec(append([]string{c.SourceVCSCommand}, args...))
+	return c.exec(append([]string{c.SourceVCS.Command}, args...))
 }
diff --git a/cmd/update.go b/cmd/update.go
new file mode 100644
index 000000000..89bab0190
--- /dev/null
+++ b/cmd/update.go
@@ -0,0 +1,63 @@
+package cmd
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/spf13/cobra"
+	vfs "github.com/twpayne/go-vfs"
+)
+
+type updateCommandConfig struct {
+	apply bool
+}
+
+var updateCommand = &cobra.Command{
+	Use:   "update",
+	Args:  cobra.NoArgs,
+	Short: "Pull changes from the source VCS and apply any changes",
+	RunE:  makeRunE(config.runUpdateCommand),
+}
+
+func init() {
+	rootCommand.AddCommand(updateCommand)
+
+	persistentFlags := updateCommand.PersistentFlags()
+	persistentFlags.BoolVarP(&config.update.apply, "apply", "a", true, "apply after pulling")
+}
+
+func (c *Config) runUpdateCommand(fs vfs.FS, args []string) error {
+	vcsInfo, err := c.getVCSInfo()
+	if err != nil {
+		return err
+	}
+	var pullArgs []string
+	if c.SourceVCS.Pull != nil {
+		switch v := c.SourceVCS.Pull.(type) {
+		case string:
+			pullArgs = strings.Split(v, " ")
+		case []string:
+			pullArgs = v
+		default:
+			return fmt.Errorf("sourceVCS.pull: cannot parse value")
+		}
+	} else {
+		pullArgs = vcsInfo.pullArgs
+	}
+	if pullArgs == nil {
+		return fmt.Errorf("%s: pull not supported", c.SourceVCS.Command)
+	}
+
+	if err := c.run(c.SourceDir, c.SourceVCS.Command, pullArgs...); err != nil {
+		return err
+	}
+
+	if c.update.apply {
+		mutator := c.getDefaultMutator(fs)
+		if err := c.applyArgs(fs, nil, mutator); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
diff --git a/cmd/vcs_infos.go b/cmd/vcs_infos.go
new file mode 100644
index 000000000..47897884c
--- /dev/null
+++ b/cmd/vcs_infos.go
@@ -0,0 +1,47 @@
+package cmd
+
+import (
+	"regexp"
+)
+
+type vcsInfo struct {
+	cloneArgsFunc func(string, string) []string
+	initArgs      []string
+	pullArgs      []string
+	versionArgs   []string
+	versionRegexp *regexp.Regexp
+}
+
+var (
+	vcsInfos = map[string]*vcsInfo{
+		"bzr": &vcsInfo{
+			versionArgs:   []string{"--version"},
+			versionRegexp: regexp.MustCompile(`^Bazaar (bzr) (\d+\.\d+\.\d+)`),
+		},
+		"cvs": &vcsInfo{
+			versionArgs:   []string{"--version"},
+			versionRegexp: regexp.MustCompile(`^Concurrent Versions System \(CVS\) (\d+\.\d+\.\d+)`),
+		},
+		"git": &vcsInfo{
+			cloneArgsFunc: func(repo, dir string) []string {
+				return []string{"clone", repo, dir}
+			},
+			initArgs:      []string{"init"},
+			pullArgs:      []string{"pull", "--rebase"},
+			versionArgs:   []string{"version"},
+			versionRegexp: regexp.MustCompile(`^git version (\d+\.\d+\.\d+)`),
+		},
+		"hg": &vcsInfo{
+			cloneArgsFunc: func(repo, dir string) []string {
+				return []string{"clone", repo, dir}
+			},
+			initArgs:      []string{"init"},
+			versionArgs:   []string{"version"},
+			versionRegexp: regexp.MustCompile(`^Mercurial Distributed SCM \(version (\d+\.\d+\.\d+\))`),
+		},
+		"svn": &vcsInfo{
+			versionArgs:   []string{"--version"},
+			versionRegexp: regexp.MustCompile(`^svn, version (\d+\.\d+\.\d+)`),
+		},
+	}
+)
diff --git a/lib/chezmoi/dir.go b/lib/chezmoi/dir.go
index f164f7046..f286e1123 100644
--- a/lib/chezmoi/dir.go
+++ b/lib/chezmoi/dir.go
@@ -93,7 +93,7 @@ func (d *Dir) Apply(fs vfs.FS, destDir string, ignore func(string) bool, umask o
 	targetPath := filepath.Join(destDir, d.targetName)
 	info, err := fs.Lstat(targetPath)
 	switch {
-	case err == nil && info.Mode().IsDir():
+	case err == nil && info.IsDir():
 		if info.Mode().Perm() != d.Perm&^umask {
 			if err := mutator.Chmod(targetPath, d.Perm&^umask); err != nil {
 				return err
diff --git a/lib/chezmoi/target_state.go b/lib/chezmoi/target_state.go
index 964e78571..7b5e50b22 100644
--- a/lib/chezmoi/target_state.go
+++ b/lib/chezmoi/target_state.go
@@ -98,7 +98,7 @@ func (ts *TargetState) Add(fs vfs.FS, addOptions AddOptions, targetPath string,
 	}
 
 	switch {
-	case info.Mode().IsDir():
+	case info.IsDir():
 		perm := info.Mode().Perm()
 		infos, err := fs.ReadDir(targetPath)
 		if err != nil {
@@ -260,7 +260,7 @@ func (ts *TargetState) Populate(fs vfs.FS) error {
 			return nil
 		}
 		switch {
-		case info.Mode().IsDir():
+		case info.IsDir():
 			components := splitPathList(relPath)
 			das := parseDirNameComponents(components)
 			dns := dirNames(das)


Test patch:
diff --git a/cmd/add_test.go b/cmd/add_test.go
index 41120132d..c3c2be0af 100644
--- a/cmd/add_test.go
+++ b/cmd/add_test.go
@@ -299,12 +299,14 @@ func TestAddCommand(t *testing.T) {
 	} {
 		t.Run(tc.name, func(t *testing.T) {
 			c := &Config{
-				SourceDir:        "/home/user/.chezmoi",
-				DestDir:          "/home/user",
-				Umask:            022,
-				DryRun:           false,
-				Verbose:          true,
-				SourceVCSCommand: "git",
+				SourceDir: "/home/user/.chezmoi",
+				DestDir:   "/home/user",
+				Umask:     022,
+				DryRun:    false,
+				Verbose:   true,
+				SourceVCS: sourceVCSConfig{
+					Command: "git",
+				},
 				Data: map[string]interface{}{
 					"name":  "John Smith",
 					"email": "john.smith@company.com",
diff --git a/cmd/init_test.go b/cmd/init_test.go
deleted file mode 100644
index f458993b6..000000000
--- a/cmd/init_test.go
+++ /dev/null
@@ -1,45 +0,0 @@
-package cmd
-
-import (
-	"testing"
-
-	"github.com/twpayne/go-vfs/vfst"
-)
-
-func TestInit(t *testing.T) {
-	c := &Config{
-		SourceDir:        "/home/user/.local/share/chezmoi",
-		DestDir:          "/home/user",
-		SourceVCSCommand: "test", // hack to work around untestable Config.exec(…)
-		Umask:            022,
-		DryRun:           false,
-		Verbose:          true,
-	}
-	fs, cleanup, err := vfst.NewTestFS(map[string]interface{}{
-		"/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{"git@github.com:example/dotfiles.git"}
-	if err := c.runInitCommand(fs, args); err != nil {
-		t.Errorf("c.runInitCommand(fs, nil, %+v) == %v, want <nil>", args, err)
-	}
-	vfst.RunTests(t, fs, "",
-		vfst.TestPath("/home/user/.local",
-			vfst.TestIsDir,
-			vfst.TestModePerm(0700),
-		),
-		vfst.TestPath("/home/user/.local/share",
-			vfst.TestIsDir,
-			vfst.TestModePerm(0700),
-		),
-		vfst.TestPath("/home/user/.local/share/chezmoi",
-			vfst.TestIsDir,
-			vfst.TestModePerm(0700),
-		),
-	)
-}