Repository: twpayne/chezmoi
Pull request #432 (2019-09-24 00:25:22)

Add autocommit and autopush
This is a draft PR working towards fixing #413.

cc @lewisacidic

lolilijve: Looking forward, I think this feature is very useful

Patch:
diff --git a/cmd/add.go b/cmd/add.go
index 027459b46..006789716 100644
--- a/cmd/add.go
+++ b/cmd/add.go
@@ -12,14 +12,15 @@ import (
 )
 
 var addCmd = &cobra.Command{
-	Use:     "add targets...",
-	Aliases: []string{"manage"},
-	Args:    cobra.MinimumNArgs(1),
-	Short:   "Add an existing file, directory, or symlink to the source state",
-	Long:    mustGetLongHelp("add"),
-	Example: getExample("add"),
-	PreRunE: config.ensureNoError,
-	RunE:    makeRunE(config.runAddCmd),
+	Use:      "add targets...",
+	Aliases:  []string{"manage"},
+	Args:     cobra.MinimumNArgs(1),
+	Short:    "Add an existing file, directory, or symlink to the source state",
+	Long:     mustGetLongHelp("add"),
+	Example:  getExample("add"),
+	PreRunE:  config.ensureNoError,
+	RunE:     makeRunE(config.runAddCmd),
+	PostRunE: makeRunE(config.autoCommitAndAutoPush),
 }
 
 type addCmdConfig struct {
diff --git a/cmd/config.go b/cmd/config.go
index 6c90e80a4..53f635d6d 100644
--- a/cmd/config.go
+++ b/cmd/config.go
@@ -2,6 +2,7 @@ package cmd
 
 import (
 	"bufio"
+	"bytes"
 	"encoding/json"
 	"errors"
 	"fmt"
@@ -16,6 +17,7 @@ import (
 	"unicode"
 
 	"github.com/BurntSushi/toml"
+	packr "github.com/gobuffalo/packr/v2"
 	"github.com/spf13/cobra"
 	"github.com/spf13/viper"
 	"github.com/twpayne/chezmoi/internal/chezmoi"
@@ -26,9 +28,11 @@ import (
 )
 
 type sourceVCSConfig struct {
-	Command string
-	Init    interface{}
-	Pull    interface{}
+	Command    string
+	AutoCommit bool
+	AutoPush   bool
+	Init       interface{}
+	Pull       interface{}
 }
 
 // A Config represents a configuration.
@@ -96,6 +100,8 @@ var (
 		"ID":   {},
 		"URL":  {},
 	}
+
+	templatesBox = packr.New("templates", "../templates")
 )
 
 // Stderr returns c's stderr.
@@ -164,6 +170,67 @@ func (c *Config) applyArgs(fs vfs.FS, args []string, mutator chezmoi.Mutator, pe
 	return nil
 }
 
+func (c *Config) autoCommit(fs vfs.FS, vcs VCS) error {
+	addArgs := vcs.AddArgs(".")
+	if addArgs == nil {
+		return fmt.Errorf("%s: autocommit not supported", c.SourceVCS.Command)
+	}
+	if err := c.run(fs, c.SourceDir, c.SourceVCS.Command, addArgs...); err != nil {
+		return err
+	}
+	output, err := c.output(fs, c.SourceDir, c.SourceVCS.Command, vcs.StatusArgs()...)
+	if err != nil {
+		return err
+	}
+	status, err := vcs.ParseStatusOutput(output)
+	if err != nil {
+		return err
+	}
+	commitMessageText, err := templatesBox.Find("COMMIT_MESSAGE.tmpl")
+	if err != nil {
+		return err
+	}
+	commitMessageTmpl, err := template.New("commit_message").Funcs(c.templateFuncs).Parse(string(commitMessageText))
+	if err != nil {
+		return err
+	}
+	b := &bytes.Buffer{}
+	if err := commitMessageTmpl.Execute(b, status); err != nil {
+		return err
+	}
+	commitArgs := vcs.CommitArgs(b.String())
+	return c.run(fs, c.SourceDir, c.SourceVCS.Command, commitArgs...)
+}
+
+func (c *Config) autoCommitAndAutoPush(fs vfs.FS, args []string) error {
+	vcs, err := c.getVCS()
+	if err != nil {
+		return err
+	}
+	if c.DryRun {
+		return nil
+	}
+	if c.SourceVCS.AutoCommit || c.SourceVCS.AutoPush {
+		if err := c.autoCommit(fs, vcs); err != nil {
+			return err
+		}
+	}
+	if c.SourceVCS.AutoPush {
+		if err := c.autoPush(fs, vcs); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (c *Config) autoPush(fs vfs.FS, vcs VCS) error {
+	pushArgs := vcs.PushArgs()
+	if pushArgs == nil {
+		return fmt.Errorf("%s: autopush not supported", c.SourceVCS.Command)
+	}
+	return c.run(fs, c.SourceDir, c.SourceVCS.Command, pushArgs...)
+}
+
 // ensureNoError ensures that no error was encountered when loading c.
 func (c *Config) ensureNoError(cmd *cobra.Command, args []string) error {
 	if c.err != nil {
@@ -312,6 +379,28 @@ func (c *Config) getVCS() (VCS, error) {
 	return vcs, nil
 }
 
+func (c *Config) output(fs vfs.FS, dir, name string, argv ...string) ([]byte, error) {
+	if c.Verbose {
+		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, nil
+	}
+	cmd := exec.Command(name, argv...)
+	if dir != "" {
+		var err error
+		cmd.Dir, err = fs.RawPath(dir)
+		if err != nil {
+			return nil, err
+		}
+	}
+	return cmd.Output()
+}
+
 //nolint:unparam
 func (c *Config) prompt(s, choices string) (byte, error) {
 	r := bufio.NewReader(c.Stdin())
diff --git a/cmd/edit.go b/cmd/edit.go
index b6f0e9ef1..096c88ad1 100644
--- a/cmd/edit.go
+++ b/cmd/edit.go
@@ -13,12 +13,13 @@ import (
 )
 
 var editCmd = &cobra.Command{
-	Use:     "edit targets...",
-	Short:   "Edit the source state of a target",
-	Long:    mustGetLongHelp("edit"),
-	Example: getExample("edit"),
-	PreRunE: config.ensureNoError,
-	RunE:    makeRunE(config.runEditCmd),
+	Use:      "edit targets...",
+	Short:    "Edit the source state of a target",
+	Long:     mustGetLongHelp("edit"),
+	Example:  getExample("edit"),
+	PreRunE:  config.ensureNoError,
+	RunE:     makeRunE(config.runEditCmd),
+	PostRunE: makeRunE(config.autoCommitAndAutoPush),
 }
 
 type editCmdConfig struct {
@@ -94,12 +95,6 @@ func (c *Config) runEditCmd(fs vfs.FS, args []string) error {
 		}
 	}
 
-	// Short path: if no post-edit actions are required then exec the editor
-	// directly.
-	if !c.edit.diff && !c.edit.apply && len(encryptedFiles) == 0 {
-		return c.execEditor(fs, argv...)
-	}
-
 	// If any of the files are encrypted, create a temporary directory to store
 	// the plaintext contents, decrypt each of them, and update argv to point to
 	// the plaintext file.
diff --git a/cmd/forget.go b/cmd/forget.go
index 1aed4ac48..da9363be8 100644
--- a/cmd/forget.go
+++ b/cmd/forget.go
@@ -8,14 +8,15 @@ import (
 )
 
 var forgetCmd = &cobra.Command{
-	Use:     "forget targets...",
-	Aliases: []string{"unmanage"},
-	Args:    cobra.MinimumNArgs(1),
-	Short:   "Remove a target from the source state",
-	Long:    mustGetLongHelp("forget"),
-	Example: getExample("forget"),
-	PreRunE: config.ensureNoError,
-	RunE:    makeRunE(config.runForgetCmd),
+	Use:      "forget targets...",
+	Aliases:  []string{"unmanage"},
+	Args:     cobra.MinimumNArgs(1),
+	Short:    "Remove a target from the source state",
+	Long:     mustGetLongHelp("forget"),
+	Example:  getExample("forget"),
+	PreRunE:  config.ensureNoError,
+	RunE:     makeRunE(config.runForgetCmd),
+	PostRunE: makeRunE(config.autoCommitAndAutoPush),
 }
 
 func init() {
diff --git a/cmd/gitvcs.go b/cmd/gitvcs.go
index 07213e112..9e775196f 100644
--- a/cmd/gitvcs.go
+++ b/cmd/gitvcs.go
@@ -1,23 +1,47 @@
 package cmd
 
-import "regexp"
+import (
+	"regexp"
+
+	"github.com/twpayne/chezmoi/internal/git"
+)
 
 var gitVersionRegexp = regexp.MustCompile(`^git version (\d+\.\d+\.\d+)`)
 
 type gitVCS struct{}
 
+func (gitVCS) AddArgs(path string) []string {
+	return []string{"add", path}
+}
+
 func (gitVCS) CloneArgs(repo, dir string) []string {
 	return []string{"clone", repo, dir}
 }
 
+func (gitVCS) CommitArgs(message string) []string {
+	return []string{"commit", "--message", message}
+}
+
 func (gitVCS) InitArgs() []string {
 	return []string{"init"}
 }
 
+func (gitVCS) ParseStatusOutput(output []byte) (interface{}, error) {
+	return git.ParseStatusPorcelainV2(output)
+}
+
 func (gitVCS) PullArgs() []string {
 	return []string{"pull", "--rebase"}
 }
 
+func (gitVCS) PushArgs() []string {
+	return []string{"push"}
+}
+
+func (gitVCS) StatusArgs() []string {
+	return []string{"status", "--porcelain=v2"}
+}
+
 func (gitVCS) VersionArgs() []string {
 	return []string{"version"}
 }
diff --git a/cmd/hgvcs.go b/cmd/hgvcs.go
index d7d3f83a4..1b6fa3536 100644
--- a/cmd/hgvcs.go
+++ b/cmd/hgvcs.go
@@ -6,18 +6,38 @@ var hgVersionRegexp = regexp.MustCompile(`^Mercurial Distributed SCM \(version (
 
 type hgVCS struct{}
 
+func (hgVCS) AddArgs(path string) []string {
+	return nil
+}
+
 func (hgVCS) CloneArgs(repo, dir string) []string {
 	return []string{"clone", repo, dir}
 }
 
+func (hgVCS) CommitArgs(message string) []string {
+	return nil
+}
+
 func (hgVCS) InitArgs() []string {
 	return []string{"init"}
 }
 
+func (hgVCS) ParseStatusOutput(output []byte) (interface{}, error) {
+	return nil, nil
+}
+
 func (hgVCS) PullArgs() []string {
 	return []string{"pull", "--rebase", "--update"}
 }
 
+func (hgVCS) PushArgs() []string {
+	return nil
+}
+
+func (hgVCS) StatusArgs() []string {
+	return nil
+}
+
 func (hgVCS) VersionArgs() []string {
 	return []string{"version"}
 }
diff --git a/cmd/remove.go b/cmd/remove.go
index d51bb9546..36648c040 100644
--- a/cmd/remove.go
+++ b/cmd/remove.go
@@ -14,14 +14,15 @@ type removeCmdConfig struct {
 }
 
 var removeCmd = &cobra.Command{
-	Use:     "remove targets...",
-	Aliases: []string{"rm"},
-	Args:    cobra.MinimumNArgs(1),
-	Short:   "Remove a target from the source state and the destination directory",
-	Long:    mustGetLongHelp("remove"),
-	Example: getExample("remove"),
-	PreRunE: config.ensureNoError,
-	RunE:    makeRunE(config.runRemoveCmd),
+	Use:      "remove targets...",
+	Aliases:  []string{"rm"},
+	Args:     cobra.MinimumNArgs(1),
+	Short:    "Remove a target from the source state and the destination directory",
+	Long:     mustGetLongHelp("remove"),
+	Example:  getExample("remove"),
+	PreRunE:  config.ensureNoError,
+	RunE:     makeRunE(config.runRemoveCmd),
+	PostRunE: makeRunE(config.autoCommitAndAutoPush),
 }
 
 func init() {
diff --git a/cmd/vcs.go b/cmd/vcs.go
index dc5a3b81d..ba3c7e540 100644
--- a/cmd/vcs.go
+++ b/cmd/vcs.go
@@ -4,9 +4,14 @@ import "regexp"
 
 // A VCS is a version control system.
 type VCS interface {
+	AddArgs(string) []string
 	CloneArgs(string, string) []string
+	CommitArgs(string) []string
 	InitArgs() []string
+	ParseStatusOutput([]byte) (interface{}, error)
 	PullArgs() []string
+	PushArgs() []string
+	StatusArgs() []string
 	VersionArgs() []string
 	VersionRegexp() *regexp.Regexp
 }
diff --git a/docs/HOWTO.md b/docs/HOWTO.md
index 412ca0b9b..3555c9cf6 100644
--- a/docs/HOWTO.md
+++ b/docs/HOWTO.md
@@ -26,6 +26,7 @@
 * [Use a non-git version control system](#use-a-non-git-version-control-system)
 * [Use a merge tool other than vimdiff](#use-a-merge-tool-other-than-vimdiff)
 * [Migrate from a dotfile manager that uses symlinks](#migrate-from-a-dotfile-manager-that-uses-symlinks)
+* [Automatically commit and push changes to your repo](#automatically-commit-and-push-changes-to-your-repo)
 
 ## Use a hosted repo to manage your dotfiles across multiple machines
 
@@ -202,7 +203,7 @@ of patterns of files to remove. When you run
 
 chezmoi will remove anything in the target directory that matches the pattern.
 As this command is potentially dangerous, you should run chezmoi in verbose,
-dry-run mode beforehand to see what would be deleted:
+dry-run mode beforehand to see what would be removed:
 
     chezmoi apply --remove --dry-run --verbose
 
@@ -592,4 +593,28 @@ not the file. To assist with migrating from symlink-based systems, use the
 This will tell `chezmoi add` that the target state of `~/.bashrc` is the target
 of the `~/.bashrc` symlink, rather than the symlink itself. When you run
 `chezmoi apply`, chezmoi will replace the `~/.bashrc` symlink with the file
-contents.
\ No newline at end of file
+contents.
+
+# Explore experimental features
+
+## Automatically commit and push changes to your repo
+
+chezmoi includes an experimental feature to automatically commit and push
+changes to your source directory to your repo. This feature is disabled by
+default. To enable it, add the following to your config file:
+
+    [sourceVCS]
+        autoCommit = true
+        autoPush = true
+
+Whenever a change is made to your source directory, chezmoi will commit the
+changes with an automatically-generated commit message (if `autoCommit` is true)
+and push them to your repo (if `autoPush` is true). `autoPush` implies
+`autoCommit`, i.e. if `autoPush` is true then chezmoi will auto-commit your
+changes. If you only set `autoCommit` to true then changes will be commited but
+not pushed.
+
+`autoCommit` and `autoPush` are experimental features, likely to contain bugs
+(especially in corner cases) and will change in the future. If you encounter a
+problem or strange behavior, please [open an
+issue](https://github.com/twpayne/chezmoi/issues/new).
\ No newline at end of file
diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md
index 7b391ebfd..478aca719 100644
--- a/docs/REFERENCE.md
+++ b/docs/REFERENCE.md
@@ -156,32 +156,34 @@ chezmoi, and the first config file found is used.
 
 The following configuration variables are available:
 
-| Variable                | Type     | Default value             | Description                         |
-| ----------------------- | -------- | ------------------------- | ----------------------------------- |
-| `bitwarden.command`     | string   | `bw`                      | Bitwarden CLI command               |
-| `color`                 | string   | `auto`                    | Colorize diffs                      |
-| `data`                  | any      | *none*                    | Template data                       |
-| `destDir`               | string   | `~`                       | Destination directory               |
-| `dryRun`                | bool     | `false`                   | Dry run mode                        |
-| `follow`                | bool     | `false`                   | Follow symlinks                     |
-| `genericSecret.command` | string   | *none*                    | Generic secret command              |
-| `gopass.command`        | string   | `gopass`                  | gopass CLI command                  |
-| `gpg.recipient`         | string   | *none*                    | GPG recipient                       |
-| `gpg.symmetric`         | bool     | `false`                   | Use symmetric GPG encryption        |
-| `keepassxc.args`        | []string | *none*                    | Extra args to KeePassXC CLI command |
-| `keepassxc.command`     | string   | `keepassxc-cli`           | KeePassXC CLI command               |
-| `keepassxc.database`    | string   | *none*                    | KeePassXC database                  |
-| `lastpass.command`      | string   | `lpass`                   | Lastpass CLI command                |
-| `merge.args`            | []string | *none*                    | Extra args to 3-way merge command   |
-| `merge.command`         | string   | `vimdiff`                 | 3-way merge command                 |
-| `onepassword.command`   | string   | `op`                      | 1Password CLI command               |
-| `pass.command`          | string   | `pass`                    | Pass CLI command                    |
-| `remove`                | bool     | `false`                   | Remove targets                      |
-| `sourceDir`             | string   | `~/.config/share/chezmoi` | Source directory                    |
-| `sourceVCS.command`     | string   | `git`                     | Source version control system       |
-| `umask`                 | int      | *from system*             | Umask                               |
-| `vault.command`         | string   | `vault`                   | Vault CLI command                   |
-| `verbose`               | bool     | `false`                   | Verbose mode                        |
+| Variable                | Type     | Default value             | Description                                         |
+| ----------------------- | -------- | ------------------------- | --------------------------------------------------- |
+| `bitwarden.command`     | string   | `bw`                      | Bitwarden CLI command                               |
+| `color`                 | string   | `auto`                    | Colorize diffs                                      |
+| `data`                  | any      | *none*                    | Template data                                       |
+| `destDir`               | string   | `~`                       | Destination directory                               |
+| `dryRun`                | bool     | `false`                   | Dry run mode                                        |
+| `follow`                | bool     | `false`                   | Follow symlinks                                     |
+| `genericSecret.command` | string   | *none*                    | Generic secret command                              |
+| `gopass.command`        | string   | `gopass`                  | gopass CLI command                                  |
+| `gpg.recipient`         | string   | *none*                    | GPG recipient                                       |
+| `gpg.symmetric`         | bool     | `false`                   | Use symmetric GPG encryption                        |
+| `keepassxc.args`        | []string | *none*                    | Extra args to KeePassXC CLI command                 |
+| `keepassxc.command`     | string   | `keepassxc-cli`           | KeePassXC CLI command                               |
+| `keepassxc.database`    | string   | *none*                    | KeePassXC database                                  |
+| `lastpass.command`      | string   | `lpass`                   | Lastpass CLI command                                |
+| `merge.args`            | []string | *none*                    | Extra args to 3-way merge command                   |
+| `merge.command`         | string   | `vimdiff`                 | 3-way merge command                                 |
+| `onepassword.command`   | string   | `op`                      | 1Password CLI command                               |
+| `pass.command`          | string   | `pass`                    | Pass CLI command                                    |
+| `remove`                | bool     | `false`                   | Remove targets                                      |
+| `sourceDir`             | string   | `~/.config/share/chezmoi` | Source directory                                    |
+| `sourceVCS.autoCommit`  | bool     | `false`                   | Commit changes to the source state after any change |
+| `sourceVCS.autoPush`    | bool     | `false`                   | Push changes to the source state after any change   |
+| `sourceVCS.command`     | string   | `git`                     | Source version control system                       |
+| `umask`                 | int      | *from system*             | Umask                                               |
+| `vault.command`         | string   | `vault`                   | Vault CLI command                                   |
+| `verbose`               | bool     | `false`                   | Verbose mode                                        |
 
 In addition, a number of secret manager integrations add configuration
 variables. These are documented in the secret manager section.
diff --git a/go.mod b/go.mod
index 83efc5f70..01d6b4598 100644
--- a/go.mod
+++ b/go.mod
@@ -34,7 +34,7 @@ require (
 	github.com/stretchr/objx v0.2.0 // indirect
 	github.com/stretchr/testify v1.4.0
 	github.com/twpayne/go-shell v0.1.0
-	github.com/twpayne/go-vfs v1.3.5
+	github.com/twpayne/go-vfs v1.3.6
 	github.com/twpayne/go-vfsafero v1.0.0
 	github.com/twpayne/go-xdg/v3 v3.1.0
 	github.com/zalando/go-keyring v0.0.0-20190913082157-62750a1ff80d
diff --git a/go.sum b/go.sum
index 1db6e106e..ac5bbfe89 100644
--- a/go.sum
+++ b/go.sum
@@ -194,8 +194,8 @@ github.com/twpayne/go-shell v0.1.0 h1:1rb6SwXRU7hWBBZVJgH/Mv+81hkaeYgVOR7JIJcvm/
 github.com/twpayne/go-shell v0.1.0/go.mod h1:H/gzux0DOH5jsjQSHXs6rs2Onxy+V4j6ycZTOulC0l8=
 github.com/twpayne/go-vfs v1.0.1/go.mod h1:OIXA6zWkcn7Jk46XT7ceYqBMeIkfzJ8WOBhGJM0W4y8=
 github.com/twpayne/go-vfs v1.0.5/go.mod h1:OIXA6zWkcn7Jk46XT7ceYqBMeIkfzJ8WOBhGJM0W4y8=
-github.com/twpayne/go-vfs v1.3.5 h1:VMwA5IJF0zGGAMqofyFb7+nv324ilFHR9JPZC5m9c74=
-github.com/twpayne/go-vfs v1.3.5/go.mod h1:BH2oQurpkb3roQDR7hZH+9DITZidl6JHOEfHlCModXY=
+github.com/twpayne/go-vfs v1.3.6 h1:dKR5suwT6WnNweNNQjts3Wih/sBTHLt4XbbEbapnWEE=
+github.com/twpayne/go-vfs v1.3.6/go.mod h1:BH2oQurpkb3roQDR7hZH+9DITZidl6JHOEfHlCModXY=
 github.com/twpayne/go-vfsafero v1.0.0 h1:ZlH32HF4OoVX/aRqc5bZa+2+M+/ezmJ4XYpT0ShtZNc=
 github.com/twpayne/go-vfsafero v1.0.0/go.mod h1:rs2H15b2z0euJzwyoBS63eUHZgBhNXVQfIFfRp8DKEk=
 github.com/twpayne/go-xdg/v3 v3.1.0 h1:AxX5ZLJIzqYHJh+4uGxWT97ySh1ND1bJLjqMxdYF+xs=
diff --git a/internal/git/status.go b/internal/git/status.go
new file mode 100644
index 000000000..4c560ba38
--- /dev/null
+++ b/internal/git/status.go
@@ -0,0 +1,237 @@
+package git
+
+import (
+	"bufio"
+	"bytes"
+	"fmt"
+	"regexp"
+	"strconv"
+)
+
+// A ParseError is a parse error.
+type ParseError string
+
+// An OrdinaryStatus is a status of a modified file.
+type OrdinaryStatus struct {
+	X    byte
+	Y    byte
+	Sub  string
+	MH   int
+	MI   int
+	MW   int
+	HH   string
+	HI   string
+	Path string
+}
+
+// A RenamedOrCopiedStatus is a status of a renamed or copied file.
+type RenamedOrCopiedStatus struct {
+	X        byte
+	Y        byte
+	Sub      string
+	MH       int
+	MI       int
+	MW       int
+	HH       string
+	HI       string
+	RC       byte
+	Score    int
+	Path     string
+	OrigPath string
+}
+
+// An UnmergedStatus is the status of an unmerged file.
+type UnmergedStatus struct {
+	X    byte
+	Y    byte
+	Sub  string
+	M1   int
+	M2   int
+	M3   int
+	MW   int
+	H1   string
+	H2   string
+	H3   string
+	Path string
+}
+
+// An UntrackedStatus is a status of an untracked file.
+type UntrackedStatus struct {
+	Path string
+}
+
+// An IgnoredStatus is a status of an ignored file.
+type IgnoredStatus struct {
+	Path string
+}
+
+// A Status is a status.
+type Status struct {
+	Ordinary        []OrdinaryStatus
+	RenamedOrCopied []RenamedOrCopiedStatus
+	Unmerged        []UnmergedStatus
+	Untracked       []UntrackedStatus
+	Ignored         []IgnoredStatus
+}
+
+//nolint:gochecknoglobals
+var (
+	statusPorcelainV2ZOrdinaryRegexp = regexp.MustCompile(`` +
+		`^1 ` +
+		`([!\.\?ACDMRU])([!\.\?ACDMRU]) ` +
+		`(N\.\.\.|S[\.C][\.M][\.U]) ` +
+		`([0-7]+) ` +
+		`([0-7]+) ` +
+		`([0-7]+) ` +
+		`([0-9a-f]+) ` +
+		`([0-9a-f]+) ` +
+		`(.*)` +
+		`$`,
+	)
+	statusPorcelainV2ZRenamedOrCopiedRegexp = regexp.MustCompile(`` +
+		`^2 ` +
+		`([!\.\?ACDMRU])([!\.\?ACDMRU]) ` +
+		`(N\.\.\.|S[\.C][\.M][\.U]) ` +
+		`([0-7]+) ` +
+		`([0-7]+) ` +
+		`([0-7]+) ` +
+		`([0-9a-f]+) ` +
+		`([0-9a-f]+) ` +
+		`([CR])([0-9]+) ` +
+		`(.*?) (.*)` +
+		`$`,
+	)
+	statusPorcelainV2ZUnmergedRegexp = regexp.MustCompile(`` +
+		`^u ` +
+		`([!\.\?ACDMRU])([!\.\?ACDMRU]) ` +
+		`(N\.\.\.|S[\.C][\.M][\.U]) ` +
+		`([0-7]+) ` +
+		`([0-7]+) ` +
+		`([0-7]+) ` +
+		`([0-7]+) ` +
+		`([0-9a-f]+) ` +
+		`([0-9a-f]+) ` +
+		`([0-9a-f]+) ` +
+		`(.*)` +
+		`$`,
+	)
+	statusPorcelainV2ZUntrackedRegexp = regexp.MustCompile(`` +
+		`^\? ` +
+		`(.*)` +
+		`$`,
+	)
+	statusPorcelainV2ZIgnoredRegexp = regexp.MustCompile(`` +
+		`^! ` +
+		`(.*)` +
+		`$`,
+	)
+)
+
+func (e ParseError) Error() string {
+	return fmt.Sprintf("cannot parse %q", string(e))
+}
+
+// ParseStatusPorcelainV2 parses the output of
+//   git status --ignored --porcelain=v2
+// See https://git-scm.com/docs/git-status.
+func ParseStatusPorcelainV2(output []byte) (*Status, error) {
+	status := &Status{}
+	s := bufio.NewScanner(bytes.NewReader(output))
+	for s.Scan() {
+		text := s.Text()
+		switch text[0] {
+		case '1':
+			m := statusPorcelainV2ZOrdinaryRegexp.FindStringSubmatchIndex(text)
+			if m == nil {
+				return nil, ParseError(text)
+			}
+			var (
+				mH, _ = strconv.ParseInt(text[m[8]:m[9]], 8, 64)
+				mI, _ = strconv.ParseInt(text[m[10]:m[11]], 8, 64)
+				mW, _ = strconv.ParseInt(text[m[12]:m[13]], 8, 64)
+			)
+			os := OrdinaryStatus{
+				X:    text[m[2]],
+				Y:    text[m[4]],
+				Sub:  text[m[6]:m[7]],
+				MH:   int(mH),
+				MI:   int(mI),
+				MW:   int(mW),
+				HH:   text[m[14]:m[15]],
+				HI:   text[m[16]:m[17]],
+				Path: text[m[18]:m[19]],
+			}
+			status.Ordinary = append(status.Ordinary, os)
+		case '2':
+			m := statusPorcelainV2ZRenamedOrCopiedRegexp.FindStringSubmatchIndex(text)
+			if m == nil {
+				return nil, ParseError(text)
+			}
+			var (
+				mH, _    = strconv.ParseInt(text[m[8]:m[9]], 8, 64)
+				mI, _    = strconv.ParseInt(text[m[10]:m[11]], 8, 64)
+				mW, _    = strconv.ParseInt(text[m[12]:m[13]], 8, 64)
+				score, _ = strconv.ParseInt(text[m[20]:m[21]], 10, 64)
+			)
+			rocs := RenamedOrCopiedStatus{
+				X:        text[m[2]],
+				Y:        text[m[4]],
+				Sub:      text[m[6]:m[7]],
+				MH:       int(mH),
+				MI:       int(mI),
+				MW:       int(mW),
+				HH:       text[m[14]:m[15]],
+				HI:       text[m[16]:m[17]],
+				RC:       text[m[18]],
+				Score:    int(score),
+				Path:     text[m[22]:m[23]],
+				OrigPath: text[m[24]:m[25]],
+			}
+			status.RenamedOrCopied = append(status.RenamedOrCopied, rocs)
+		case 'u':
+			m := statusPorcelainV2ZUnmergedRegexp.FindStringSubmatchIndex(text)
+			if m == nil {
+				return nil, ParseError(text)
+			}
+			var (
+				m1, _ = strconv.ParseInt(text[m[6]:m[7]], 8, 64)
+				m2, _ = strconv.ParseInt(text[m[8]:m[9]], 8, 64)
+				m3, _ = strconv.ParseInt(text[m[10]:m[11]], 8, 64)
+				mW, _ = strconv.ParseInt(text[m[12]:m[13]], 8, 64)
+			)
+			us := UnmergedStatus{
+				X:   text[m[2]],
+				Y:   text[m[4]],
+				Sub: text[m[6]:m[7]],
+				M1:  int(m1),
+				M2:  int(m2),
+				M3:  int(m3),
+				MW:  int(mW),
+			}
+			status.Unmerged = append(status.Unmerged, us)
+		case '?':
+			m := statusPorcelainV2ZUntrackedRegexp.FindStringSubmatchIndex(text)
+			if m == nil {
+				return nil, ParseError(text)
+			}
+			us := UntrackedStatus{
+				Path: text[m[2]:m[3]],
+			}
+			status.Untracked = append(status.Untracked, us)
+		case '!':
+			m := statusPorcelainV2ZIgnoredRegexp.FindStringSubmatchIndex(text)
+			if m == nil {
+				return nil, ParseError(text)
+			}
+			us := IgnoredStatus{
+				Path: text[m[2]:m[3]],
+			}
+			status.Ignored = append(status.Ignored, us)
+		case '#':
+			continue
+		default:
+			return nil, ParseError(text)
+		}
+	}
+	return status, s.Err()
+}
diff --git a/templates/COMMIT_MESSAGE.tmpl b/templates/COMMIT_MESSAGE.tmpl
new file mode 100644
index 000000000..d736a9ffd
--- /dev/null
+++ b/templates/COMMIT_MESSAGE.tmpl
@@ -0,0 +1,24 @@
+{{- /* FIXME replace 46 with '.' when https://github.com/golang/go/issues/34483 is fixed */ -}}
+{{- /* FIXME generate commit summary */ -}}
+
+{{- range .Ordinary -}}
+{{ if and (eq .X 'A') (eq .Y 46) -}}Add {{ .Path }}
+{{ else if and (eq .X 'D') (eq .Y 46) -}}Remove {{ .Path }}
+{{ else if and (eq .X 'M') (eq .Y 46) -}}Update {{ .Path }}
+{{ else }}{{with (printf "unsupported XY: %q" (printf "%c%c" .X .Y)) }}{{ fail . }}{{ end }}
+{{ end }}
+{{- end -}}
+
+{{- range .RenamedOrCopied -}}
+{{ if and (eq .X 'R') (eq .Y 46) }}Rename {{ .OrigPath }} to {{ .Path }}
+{{ else }}{{with (printf "unsupported XY: %q" (printf "%c%c" .X .Y)) }}{{ fail . }}{{ end }}
+{{ end }}
+{{- end -}}
+
+{{- range .Unmerged -}}
+{{ fail "unmerged files" }}
+{{- end -}}
+
+{{- range .Untracked -}}
+{{ fail "untracked files" }}
+{{- end -}}
\ No newline at end of file


Test patch:
diff --git a/cmd/config_test.go b/cmd/config_test.go
index 4c8ed52ec..ca4d82b6a 100644
--- a/cmd/config_test.go
+++ b/cmd/config_test.go
@@ -1,13 +1,69 @@
 package cmd
 
 import (
+	"bytes"
 	"path/filepath"
 	"testing"
+	"text/template"
 
+	"github.com/Masterminds/sprig"
 	"github.com/stretchr/testify/assert"
-	"github.com/twpayne/go-xdg/v3"
+	"github.com/stretchr/testify/require"
+	xdg "github.com/twpayne/go-xdg/v3"
 )
 
+func TestAutoCommitCommitMessage(t *testing.T) {
+	commitMessageText, err := templatesBox.Find("COMMIT_MESSAGE.tmpl")
+	require.NoError(t, err)
+	commitMessageTmpl, err := template.New("commit_message").Funcs(sprig.HermeticTxtFuncMap()).Parse(string(commitMessageText))
+	require.NoError(t, err)
+	for _, tc := range []struct {
+		name            string
+		statusStr       string
+		wantErr         bool
+		expectedMessage string
+	}{
+		{
+			name:            "add",
+			statusStr:       "1 A. N... 000000 100644 100644 0000000000000000000000000000000000000000 cea5c3500651a923bacd80f960dd20f04f71d509 main.go\n",
+			expectedMessage: "Add main.go\n",
+		},
+		{
+			name:            "remove",
+			statusStr:       "1 D. N... 100644 000000 000000 cea5c3500651a923bacd80f960dd20f04f71d509 0000000000000000000000000000000000000000 main.go\n",
+			expectedMessage: "Remove main.go\n",
+		},
+		{
+			name:            "update",
+			statusStr:       "1 M. N... 100644 100644 100644 353dbbb3c29a80fb44d4e26dac111739d25294db 353dbbb3c29a80fb44d4e26dac111739d25294db main.go\n",
+			expectedMessage: "Update main.go\n",
+		},
+		{
+			name:            "rename",
+			statusStr:       "2 R. N... 100644 100644 100644 9d06c86ecba40e1c695e69b55a40843df6a79cef 9d06c86ecba40e1c695e69b55a40843df6a79cef R100 chezmoi_rename.go chezmoi.go\n",
+			expectedMessage: "Rename chezmoi.go to chezmoi_rename.go\n",
+		},
+		{
+			name:      "unsupported_xy",
+			statusStr: "1 MM N... 100644 100644 100644 353dbbb3c29a80fb44d4e26dac111739d25294db 353dbbb3c29a80fb44d4e26dac111739d25294db main.go\n",
+			wantErr:   true,
+		},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			status, err := gitVCS{}.ParseStatusOutput([]byte(tc.statusStr))
+			require.NoError(t, err)
+			b := &bytes.Buffer{}
+			err = commitMessageTmpl.Execute(b, status)
+			if tc.wantErr {
+				require.Error(t, err)
+			} else {
+				require.NoError(t, err)
+				assert.Equal(t, lines(tc.expectedMessage), b.String())
+			}
+		})
+	}
+}
+
 func TestUpperSnakeCaseToCamelCase(t *testing.T) {
 	for s, want := range map[string]string{
 		"BUG_REPORT_URL":   "bugReportURL",
diff --git a/cmd/init_test.go b/cmd/init_test.go
index d0af9b397..77ed763f0 100644
--- a/cmd/init_test.go
+++ b/cmd/init_test.go
@@ -116,11 +116,11 @@ func TestInitRepo(t *testing.T) {
 		),
 		vfst.TestPath("/home/user/.local/share/chezmoi/dot_bashrc",
 			vfst.TestModeIsRegular,
-			vfst.TestContentsStringLines("# contents of .bashrc\n"),
+			vfst.TestContentsString(lines("# contents of .bashrc\n")),
 		),
 		vfst.TestPath("/home/user/.config/chezmoi/chezmoi.toml",
 			vfst.TestModeIsRegular,
-			vfst.TestContentsStringLines("# contents of chezmoi.toml\n"),
+			vfst.TestContentsString(lines("# contents of chezmoi.toml\n")),
 		),
 	)
 }
diff --git a/cmd/util_posix_test.go b/cmd/util_posix_test.go
new file mode 100644
index 000000000..df968515f
--- /dev/null
+++ b/cmd/util_posix_test.go
@@ -0,0 +1,7 @@
+//+build !windows
+
+package cmd
+
+func lines(s string) string {
+	return s
+}
diff --git a/cmd/util_windows_test.go b/cmd/util_windows_test.go
new file mode 100644
index 000000000..750fedf70
--- /dev/null
+++ b/cmd/util_windows_test.go
@@ -0,0 +1,9 @@
+//+build windows
+
+package cmd
+
+import "strings"
+
+func lines(s string) string {
+	return strings.Replace(s, "\n", "\r\n", -1)
+}
diff --git a/internal/git/status_test.go b/internal/git/status_test.go
new file mode 100644
index 000000000..b351a38af
--- /dev/null
+++ b/internal/git/status_test.go
@@ -0,0 +1,143 @@
+package git
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestParseStatusPorcelainV2(t *testing.T) {
+	for _, tc := range []struct {
+		name           string
+		outputStr      string
+		expectedStatus *Status
+	}{
+		{
+			name:      "added",
+			outputStr: "1 A. N... 000000 100644 100644 0000000000000000000000000000000000000000 cea5c3500651a923bacd80f960dd20f04f71d509 main.go\n",
+			expectedStatus: &Status{
+				Ordinary: []OrdinaryStatus{
+					{
+						X:    'A',
+						Y:    '.',
+						Sub:  "N...",
+						MH:   0,
+						MI:   0100644,
+						MW:   0100644,
+						HH:   "0000000000000000000000000000000000000000",
+						HI:   "cea5c3500651a923bacd80f960dd20f04f71d509",
+						Path: "main.go",
+					},
+				},
+			},
+		},
+		{
+			name:      "removed",
+			outputStr: "1 D. N... 100644 000000 000000 cea5c3500651a923bacd80f960dd20f04f71d509 0000000000000000000000000000000000000000 main.go\n",
+			expectedStatus: &Status{
+				Ordinary: []OrdinaryStatus{
+					{
+						X:    'D',
+						Y:    '.',
+						Sub:  "N...",
+						MH:   0100644,
+						MI:   0,
+						MW:   0,
+						HH:   "cea5c3500651a923bacd80f960dd20f04f71d509",
+						HI:   "0000000000000000000000000000000000000000",
+						Path: "main.go",
+					},
+				},
+			},
+		},
+		{
+			name:      "update",
+			outputStr: "1 .M N... 100644 100644 100644 353dbbb3c29a80fb44d4e26dac111739d25294db 353dbbb3c29a80fb44d4e26dac111739d25294db cmd/gitvcs.go\n",
+			expectedStatus: &Status{
+				Ordinary: []OrdinaryStatus{
+					{
+						X:    '.',
+						Y:    'M',
+						Sub:  "N...",
+						MH:   0100644,
+						MI:   0100644,
+						MW:   0100644,
+						HH:   "353dbbb3c29a80fb44d4e26dac111739d25294db",
+						HI:   "353dbbb3c29a80fb44d4e26dac111739d25294db",
+						Path: "cmd/gitvcs.go",
+					},
+				},
+			},
+		},
+		{
+			name:      "renamed",
+			outputStr: "2 R. N... 100644 100644 100644 9d06c86ecba40e1c695e69b55a40843df6a79cef 9d06c86ecba40e1c695e69b55a40843df6a79cef R100 chezmoi_rename.go chezmoi.go\n",
+			expectedStatus: &Status{
+				RenamedOrCopied: []RenamedOrCopiedStatus{
+					{
+						X:        'R',
+						Y:        '.',
+						Sub:      "N...",
+						MH:       0100644,
+						MI:       0100644,
+						MW:       0100644,
+						HH:       "9d06c86ecba40e1c695e69b55a40843df6a79cef",
+						HI:       "9d06c86ecba40e1c695e69b55a40843df6a79cef",
+						RC:       'R',
+						Score:    100,
+						Path:     "chezmoi_rename.go",
+						OrigPath: "chezmoi.go",
+					},
+				},
+			},
+		},
+		{
+			name:      "modified_2",
+			outputStr: "1 .M N... 100644 100644 100644 5716ca5987cbf97d6bb54920bea6adde242d87e6 5716ca5987cbf97d6bb54920bea6adde242d87e6 foo\n",
+			expectedStatus: &Status{
+				Ordinary: []OrdinaryStatus{
+					{
+						X:    '.',
+						Y:    'M',
+						Sub:  "N...",
+						MH:   0100644,
+						MI:   0100644,
+						MW:   0100644,
+						HH:   "5716ca5987cbf97d6bb54920bea6adde242d87e6",
+						HI:   "5716ca5987cbf97d6bb54920bea6adde242d87e6",
+						Path: "foo",
+					},
+				},
+			},
+		},
+		{
+			name:      "untracked",
+			outputStr: "? chezmoi.go\n",
+			expectedStatus: &Status{
+				Untracked: []UntrackedStatus{
+					{
+						Path: "chezmoi.go",
+					},
+				},
+			},
+		},
+		{
+			name:      "ignored",
+			outputStr: "! chezmoi.go\n",
+			expectedStatus: &Status{
+				Ignored: []IgnoredStatus{
+					{
+						Path: "chezmoi.go",
+					},
+				},
+			},
+		},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			actualStatus, err := ParseStatusPorcelainV2([]byte(tc.outputStr))
+			require.NoError(t, err)
+			assert.Equal(t, tc.expectedStatus, actualStatus)
+		})
+	}
+}