Repository: twpayne/chezmoi (go)

Issue:
Add gpg or asc suffix to encrypted files
## Is your feature request related to a problem? Please describe.

Many editors such as Emacs or vim allows to perform transparent decryption / encryption of files with `.pgg` or `.asc` suffix. It would be nice to have an option to let the user chose if he want to add a suffix or not to encrypted files.

This will also allow the user to edit files directly from the repo without having to invoque chezmoi.

## Describe the solution you'd like

An option in the configuration file such as:

```
# Use .gpg for binary encryption or .asc for armored encryption.
gpg.suffix = ".gpg"
```

## Describe alternatives you've considered

Reconfiguring the editor to handle files starting with `encrypted_` fo be handled. This is not a good solution in the long term since those changes are very chezmoi related.



Pull request:
chezmoi2: Add optional encrypted suffix
Fixes #789.

Patch:
diff --git a/chezmoi2/cmd/addcmd.go b/chezmoi2/cmd/addcmd.go
index f57df0ae8..41d523994 100644
--- a/chezmoi2/cmd/addcmd.go
+++ b/chezmoi2/cmd/addcmd.go
@@ -54,12 +54,13 @@ func (c *Config) runAddCmd(cmd *cobra.Command, args []string, sourceState *chezm
 	}
 
 	return sourceState.Add(c.sourceSystem, c.persistentState, c.destSystem, destAbsPathInfos, &chezmoi.AddOptions{
-		AutoTemplate: c.add.autoTemplate,
-		Create:       c.add.create,
-		Empty:        c.add.empty,
-		Encrypt:      c.add.encrypt,
-		Exact:        c.add.exact,
-		Include:      c.add.include,
-		Template:     c.add.template,
+		AutoTemplate:    c.add.autoTemplate,
+		Create:          c.add.create,
+		Empty:           c.add.empty,
+		Encrypt:         c.add.encrypt,
+		EncryptedSuffix: c.encryption.EncryptedSuffix(),
+		Exact:           c.add.exact,
+		Include:         c.add.include,
+		Template:        c.add.template,
 	})
 }
diff --git a/chezmoi2/cmd/chattrcmd.go b/chezmoi2/cmd/chattrcmd.go
index 7af2f8cda..7433eb83f 100644
--- a/chezmoi2/cmd/chattrcmd.go
+++ b/chezmoi2/cmd/chattrcmd.go
@@ -91,6 +91,7 @@ func (c *Config) runChattrCmd(cmd *cobra.Command, args []string, sourceState *ch
 	// directories.
 	sort.Sort(sort.Reverse(targetRelPaths))
 
+	encryptedSuffix := sourceState.Encryption().EncryptedSuffix()
 	for _, targetRelPath := range targetRelPaths {
 		sourceStateEntry := sourceState.MustEntry(targetRelPath)
 		sourceRelPath := sourceStateEntry.SourceRelPath()
@@ -109,7 +110,7 @@ func (c *Config) runChattrCmd(cmd *cobra.Command, args []string, sourceState *ch
 		case *chezmoi.SourceStateFile:
 			// FIXME encrypted attribute changes
 			// FIXME when changing encrypted attribute add new file before removing old one
-			if newBaseNameRelPath := chezmoi.RelPath(am.modifyFileAttr(sourceStateEntry.Attr).SourceName()); newBaseNameRelPath != fileRelPath {
+			if newBaseNameRelPath := chezmoi.RelPath(am.modifyFileAttr(sourceStateEntry.Attr).SourceName(encryptedSuffix)); newBaseNameRelPath != fileRelPath {
 				oldSourceAbsPath := c.sourceDirAbsPath.Join(parentRelPath, fileRelPath)
 				newSourceAbsPath := c.sourceDirAbsPath.Join(parentRelPath, newBaseNameRelPath)
 				if err := c.sourceSystem.Rename(oldSourceAbsPath, newSourceAbsPath); err != nil {
diff --git a/chezmoi2/cmd/config.go b/chezmoi2/cmd/config.go
index 6a8e96c39..d07699bb6 100644
--- a/chezmoi2/cmd/config.go
+++ b/chezmoi2/cmd/config.go
@@ -222,9 +222,11 @@ func newConfig(options ...configOption) (*Config, error) {
 		},
 		AGE: chezmoi.AGEEncryption{
 			Command: "age",
+			Suffix:  ".age",
 		},
 		GPG: chezmoi.GPGEncryption{
 			Command: "gpg",
+			Suffix:  ".asc",
 		},
 		add: addCmdConfig{
 			include:   chezmoi.NewIncludeSet(chezmoi.IncludeAll),
diff --git a/chezmoi2/cmd/reference.gen.go b/chezmoi2/cmd/reference.gen.go
index cea105e90..2cba5f82a 100644
--- a/chezmoi2/cmd/reference.gen.go
+++ b/chezmoi2/cmd/reference.gen.go
@@ -276,6 +276,7 @@ func init() {
 		"|               | `recipients`      | []string | *none*                    | age recipients                                         |\n" +
 		"|               | `recipientsFile`  | []string | *none*                    | age recipients file                                    |\n" +
 		"|               | `recipientsFiles` | []string | *none*                    | age receipients files                                  |\n" +
+		"|               | `suffix`          | string   | `.age`                    | Suffix appended to age-encrypted files                 |\n" +
 		"| `bitwarden`   | `command`         | string   | `bw`                      | Bitwarden CLI command                                  |\n" +
 		"| `cd`          | `args`            | []string | *none*                    | Extra args to shell in `cd` command                    |\n" +
 		"|               | `command`         | string   | *none*                    | Shell to run in `cd` command                           |\n" +
@@ -291,6 +292,7 @@ func init() {
 		"| `gpg`         | `args`            | []string | *none*                    | Extra args to GPG CLI command                          |\n" +
 		"|               | `command`         | string   | `gpg`                     | GPG CLI command                                        |\n" +
 		"|               | `recipient`       | string   | *none*                    | GPG recipient                                          |\n" +
+		"|               | `suffix`          | string   | `.asc`                    | Suffix appended to GPG-encrypted files                 |\n" +
 		"|               | `symmetric`       | bool     | `false`                   | Use symmetric GPG encryption                           |\n" +
 		"| `keepassxc`   | `args`            | []string | *none*                    | Extra args to KeePassXC CLI command                    |\n" +
 		"|               | `command`         | string   | `keepassxc-cli`           | KeePassXC CLI command                                  |\n" +
diff --git a/chezmoi2/docs/REFERENCE.md b/chezmoi2/docs/REFERENCE.md
index f43b9a4d3..78a55437c 100644
--- a/chezmoi2/docs/REFERENCE.md
+++ b/chezmoi2/docs/REFERENCE.md
@@ -269,6 +269,7 @@ The following configuration variables are available:
 |               | `recipients`      | []string | *none*                    | age recipients                                         |
 |               | `recipientsFile`  | []string | *none*                    | age recipients file                                    |
 |               | `recipientsFiles` | []string | *none*                    | age receipients files                                  |
+|               | `suffix`          | string   | `.age`                    | Suffix appended to age-encrypted files                 |
 | `bitwarden`   | `command`         | string   | `bw`                      | Bitwarden CLI command                                  |
 | `cd`          | `args`            | []string | *none*                    | Extra args to shell in `cd` command                    |
 |               | `command`         | string   | *none*                    | Shell to run in `cd` command                           |
@@ -284,6 +285,7 @@ The following configuration variables are available:
 | `gpg`         | `args`            | []string | *none*                    | Extra args to GPG CLI command                          |
 |               | `command`         | string   | `gpg`                     | GPG CLI command                                        |
 |               | `recipient`       | string   | *none*                    | GPG recipient                                          |
+|               | `suffix`          | string   | `.asc`                    | Suffix appended to GPG-encrypted files                 |
 |               | `symmetric`       | bool     | `false`                   | Use symmetric GPG encryption                           |
 | `keepassxc`   | `args`            | []string | *none*                    | Extra args to KeePassXC CLI command                    |
 |               | `command`         | string   | `keepassxc-cli`           | KeePassXC CLI command                                  |
diff --git a/chezmoi2/internal/chezmoi/ageencryption.go b/chezmoi2/internal/chezmoi/ageencryption.go
index 5a0ef6832..751dbadaf 100644
--- a/chezmoi2/internal/chezmoi/ageencryption.go
+++ b/chezmoi2/internal/chezmoi/ageencryption.go
@@ -20,12 +20,13 @@ type AGEEncryption struct {
 	Recipients      []string
 	RecipientsFile  string
 	RecipientsFiles []string
+	Suffix          string
 }
 
 // Decrypt implements Encyrption.Decrypt.
-func (t *AGEEncryption) Decrypt(ciphertext []byte) ([]byte, error) {
+func (e *AGEEncryption) Decrypt(ciphertext []byte) ([]byte, error) {
 	//nolint:gosec
-	cmd := exec.Command(t.Command, append(t.decryptArgs(), t.Args...)...)
+	cmd := exec.Command(e.Command, append(e.decryptArgs(), e.Args...)...)
 	cmd.Stdin = bytes.NewReader(ciphertext)
 	plaintext, err := chezmoilog.LogCmdOutput(log.Logger, cmd)
 	if err != nil {
@@ -35,17 +36,17 @@ func (t *AGEEncryption) Decrypt(ciphertext []byte) ([]byte, error) {
 }
 
 // DecryptToFile implements Encryption.DecryptToFile.
-func (t *AGEEncryption) DecryptToFile(filename string, ciphertext []byte) error {
+func (e *AGEEncryption) DecryptToFile(filename string, ciphertext []byte) error {
 	//nolint:gosec
-	cmd := exec.Command(t.Command, append(append(t.decryptArgs(), "--output", filename), t.Args...)...)
+	cmd := exec.Command(e.Command, append(append(e.decryptArgs(), "--output", filename), e.Args...)...)
 	cmd.Stdin = bytes.NewReader(ciphertext)
 	return chezmoilog.LogCmdRun(log.Logger, cmd)
 }
 
 // Encrypt implements Encryption.Encrypt.
-func (t *AGEEncryption) Encrypt(plaintext []byte) ([]byte, error) {
+func (e *AGEEncryption) Encrypt(plaintext []byte) ([]byte, error) {
 	//nolint:gosec
-	cmd := exec.Command(t.Command, append(t.encryptArgs(), t.Args...)...)
+	cmd := exec.Command(e.Command, append(e.encryptArgs(), e.Args...)...)
 	cmd.Stdin = bytes.NewReader(plaintext)
 	ciphertext, err := chezmoilog.LogCmdOutput(log.Logger, cmd)
 	if err != nil {
@@ -55,37 +56,42 @@ func (t *AGEEncryption) Encrypt(plaintext []byte) ([]byte, error) {
 }
 
 // EncryptFile implements Encryption.EncryptFile.
-func (t *AGEEncryption) EncryptFile(filename string) ([]byte, error) {
+func (e *AGEEncryption) EncryptFile(filename string) ([]byte, error) {
 	//nolint:gosec
-	cmd := exec.Command(t.Command, append(append(t.encryptArgs(), t.Args...), filename)...)
+	cmd := exec.Command(e.Command, append(append(e.encryptArgs(), e.Args...), filename)...)
 	return chezmoilog.LogCmdOutput(log.Logger, cmd)
 }
 
-func (t *AGEEncryption) decryptArgs() []string {
-	args := make([]string, 0, 1+2*(1+len(t.Identities)))
+// EncryptedSuffix implements Encryption.EncryptedSuffix.
+func (e *AGEEncryption) EncryptedSuffix() string {
+	return e.Suffix
+}
+
+func (e *AGEEncryption) decryptArgs() []string {
+	args := make([]string, 0, 1+2*(1+len(e.Identities)))
 	args = append(args, "--decrypt")
-	if t.Identity != "" {
-		args = append(args, "--identity", t.Identity)
+	if e.Identity != "" {
+		args = append(args, "--identity", e.Identity)
 	}
-	for _, identity := range t.Identities {
+	for _, identity := range e.Identities {
 		args = append(args, "--identity", identity)
 	}
 	return args
 }
 
-func (t *AGEEncryption) encryptArgs() []string {
-	args := make([]string, 0, 1+2*(1+len(t.Recipients))+2*(1+len(t.RecipientsFiles)))
+func (e *AGEEncryption) encryptArgs() []string {
+	args := make([]string, 0, 1+2*(1+len(e.Recipients))+2*(1+len(e.RecipientsFiles)))
 	args = append(args, "--armor")
-	if t.Recipient != "" {
-		args = append(args, "--recipient", t.Recipient)
+	if e.Recipient != "" {
+		args = append(args, "--recipient", e.Recipient)
 	}
-	for _, recipient := range t.Recipients {
+	for _, recipient := range e.Recipients {
 		args = append(args, "--recipient", recipient)
 	}
-	if t.RecipientsFile != "" {
-		args = append(args, "--recipients-file", t.RecipientsFile)
+	if e.RecipientsFile != "" {
+		args = append(args, "--recipients-file", e.RecipientsFile)
 	}
-	for _, recipientsFile := range t.RecipientsFiles {
+	for _, recipientsFile := range e.RecipientsFiles {
 		args = append(args, "--recipients-file", recipientsFile)
 	}
 	return args
diff --git a/chezmoi2/internal/chezmoi/attr.go b/chezmoi2/internal/chezmoi/attr.go
index 5f1f4a86d..8a845998e 100644
--- a/chezmoi2/internal/chezmoi/attr.go
+++ b/chezmoi2/internal/chezmoi/attr.go
@@ -91,7 +91,7 @@ func (da DirAttr) perm() os.FileMode {
 }
 
 // parseFileAttr parses a source file name in the source state.
-func parseFileAttr(sourceName string) FileAttr {
+func parseFileAttr(sourceName, encryptedSuffix string) FileAttr {
 	var (
 		sourceFileType = SourceFileTypeFile
 		name           = sourceName
@@ -169,6 +169,9 @@ func parseFileAttr(sourceName string) FileAttr {
 	if strings.HasPrefix(name, dotPrefix) {
 		name = "." + mustTrimPrefix(name, dotPrefix)
 	}
+	if encrypted {
+		name = strings.TrimSuffix(name, encryptedSuffix)
+	}
 	if strings.HasSuffix(name, TemplateSuffix) {
 		name = mustTrimSuffix(name, TemplateSuffix)
 		template = true
@@ -187,7 +190,7 @@ func parseFileAttr(sourceName string) FileAttr {
 }
 
 // SourceName returns fa's source name.
-func (fa FileAttr) SourceName() string {
+func (fa FileAttr) SourceName(encryptedSuffix string) string {
 	sourceName := ""
 	switch fa.Type {
 	case SourceFileTypeCreate:
@@ -244,6 +247,9 @@ func (fa FileAttr) SourceName() string {
 	if fa.Template {
 		sourceName += TemplateSuffix
 	}
+	if fa.Encrypted {
+		sourceName += encryptedSuffix
+	}
 	return sourceName
 }
 
diff --git a/chezmoi2/internal/chezmoi/debugencryption.go b/chezmoi2/internal/chezmoi/debugencryption.go
index ab9d4e7e8..9b1e93398 100644
--- a/chezmoi2/internal/chezmoi/debugencryption.go
+++ b/chezmoi2/internal/chezmoi/debugencryption.go
@@ -61,3 +61,8 @@ func (e *DebugEncryption) EncryptFile(filename string) ([]byte, error) {
 		Msg("EncryptFile")
 	return ciphertext, err
 }
+
+// EncryptedSuffix implements Encryption.EncryptedSuffix.
+func (e *DebugEncryption) EncryptedSuffix() string {
+	return e.encryption.EncryptedSuffix()
+}
diff --git a/chezmoi2/internal/chezmoi/encryption.go b/chezmoi2/internal/chezmoi/encryption.go
index 63c125035..a19220b68 100644
--- a/chezmoi2/internal/chezmoi/encryption.go
+++ b/chezmoi2/internal/chezmoi/encryption.go
@@ -6,4 +6,5 @@ type Encryption interface {
 	DecryptToFile(filename string, ciphertext []byte) error
 	Encrypt(plaintext []byte) ([]byte, error)
 	EncryptFile(filename string) ([]byte, error)
+	EncryptedSuffix() string
 }
diff --git a/chezmoi2/internal/chezmoi/gpgencryption.go b/chezmoi2/internal/chezmoi/gpgencryption.go
index 1f365a4b6..7d7c6f844 100644
--- a/chezmoi2/internal/chezmoi/gpgencryption.go
+++ b/chezmoi2/internal/chezmoi/gpgencryption.go
@@ -16,62 +16,68 @@ type GPGEncryption struct {
 	Args      []string
 	Recipient string
 	Symmetric bool
+	Suffix    string
 }
 
 // Decrypt implements Encyrption.Decrypt.
-func (t *GPGEncryption) Decrypt(ciphertext []byte) ([]byte, error) {
-	args := append([]string{"--decrypt"}, t.Args...)
+func (e *GPGEncryption) Decrypt(ciphertext []byte) ([]byte, error) {
+	args := append([]string{"--decrypt"}, e.Args...)
 	//nolint:gosec
-	cmd := exec.Command(t.Command, args...)
+	cmd := exec.Command(e.Command, args...)
 	cmd.Stdin = bytes.NewReader(ciphertext)
 	return chezmoilog.LogCmdOutput(log.Logger, cmd)
 }
 
 // DecryptToFile implements Encryption.DecryptToFile.
-func (t *GPGEncryption) DecryptToFile(filename string, ciphertext []byte) error {
+func (e *GPGEncryption) DecryptToFile(filename string, ciphertext []byte) error {
 	args := append([]string{
 		"--decrypt",
 		"--output", filename,
 		"--yes",
-	}, t.Args...)
+	}, e.Args...)
 	//nolint:gosec
-	cmd := exec.Command(t.Command, args...)
+	cmd := exec.Command(e.Command, args...)
 	cmd.Stdin = bytes.NewReader(ciphertext)
 	return chezmoilog.LogCmdRun(log.Logger, cmd)
 }
 
 // Encrypt implements Encryption.Encrypt.
-func (t *GPGEncryption) Encrypt(plaintext []byte) ([]byte, error) {
-	args := append(t.encryptArgs(), t.Args...)
+func (e *GPGEncryption) Encrypt(plaintext []byte) ([]byte, error) {
+	args := append(e.encryptArgs(), e.Args...)
 	//nolint:gosec
-	cmd := exec.Command(t.Command, args...)
+	cmd := exec.Command(e.Command, args...)
 	cmd.Stdin = bytes.NewReader(plaintext)
 	return chezmoilog.LogCmdOutput(log.Logger, cmd)
 }
 
 // EncryptFile implements Encryption.EncryptFile.
-func (t *GPGEncryption) EncryptFile(filename string) (ciphertext []byte, err error) {
+func (e *GPGEncryption) EncryptFile(filename string) (ciphertext []byte, err error) {
 	f, err := os.Open(filename)
 	if err != nil {
 		return nil, err
 	}
 	defer f.Close()
-	args := append(t.encryptArgs(), t.Args...)
+	args := append(e.encryptArgs(), e.Args...)
 	//nolint:gosec
-	cmd := exec.Command(t.Command, args...)
+	cmd := exec.Command(e.Command, args...)
 	cmd.Stdin = f
 	return chezmoilog.LogCmdOutput(log.Logger, cmd)
 }
 
-func (t *GPGEncryption) encryptArgs() []string {
+// EncryptedSuffix implements Encryption.EncryptedSuffix.
+func (e *GPGEncryption) EncryptedSuffix() string {
+	return e.Suffix
+}
+
+func (e *GPGEncryption) encryptArgs() []string {
 	args := []string{
 		"--armor",
 		"--encrypt",
 	}
-	if t.Recipient != "" {
-		args = append(args, "--recipient", t.Recipient)
+	if e.Recipient != "" {
+		args = append(args, "--recipient", e.Recipient)
 	}
-	if t.Symmetric {
+	if e.Symmetric {
 		args = append(args, "--symmetric")
 	}
 	return args
diff --git a/chezmoi2/internal/chezmoi/noencryption.go b/chezmoi2/internal/chezmoi/noencryption.go
index d81c918fb..0c70235a9 100644
--- a/chezmoi2/internal/chezmoi/noencryption.go
+++ b/chezmoi2/internal/chezmoi/noencryption.go
@@ -18,3 +18,6 @@ func (NoEncryption) Encrypt([]byte) ([]byte, error) { return nil, errNoEncryptio
 
 // EncryptFile implements Encryption.EncryptFile.
 func (NoEncryption) EncryptFile(string) ([]byte, error) { return nil, errNoEncryption }
+
+// EncryptedSuffix implements Encryption.EncryptedSuffix.
+func (NoEncryption) EncryptedSuffix() string { return "" }
diff --git a/chezmoi2/internal/chezmoi/sourcerelpath.go b/chezmoi2/internal/chezmoi/sourcerelpath.go
index afe914624..6977e5217 100644
--- a/chezmoi2/internal/chezmoi/sourcerelpath.go
+++ b/chezmoi2/internal/chezmoi/sourcerelpath.go
@@ -72,7 +72,7 @@ func (p SourceRelPath) String() string {
 }
 
 // TargetRelPath returns the relative path of p's target.
-func (p SourceRelPath) TargetRelPath() RelPath {
+func (p SourceRelPath) TargetRelPath(encryptedSuffix string) RelPath {
 	sourceNames := strings.Split(string(p.relPath), "/")
 	relPathNames := make([]string, 0, len(sourceNames))
 	if p.isDir {
@@ -85,7 +85,7 @@ func (p SourceRelPath) TargetRelPath() RelPath {
 			dirAttr := parseDirAttr(sourceName)
 			relPathNames = append(relPathNames, dirAttr.TargetName)
 		}
-		fileAttr := parseFileAttr(sourceNames[len(sourceNames)-1])
+		fileAttr := parseFileAttr(sourceNames[len(sourceNames)-1], encryptedSuffix)
 		relPathNames = append(relPathNames, fileAttr.TargetName)
 	}
 	return RelPath(strings.Join(relPathNames, "/"))
diff --git a/chezmoi2/internal/chezmoi/sourcestate.go b/chezmoi2/internal/chezmoi/sourcestate.go
index 466b18cab..62f7ff836 100644
--- a/chezmoi2/internal/chezmoi/sourcestate.go
+++ b/chezmoi2/internal/chezmoi/sourcestate.go
@@ -115,14 +115,15 @@ func NewSourceState(options ...SourceStateOption) *SourceState {
 
 // AddOptions are options to SourceState.Add.
 type AddOptions struct {
-	AutoTemplate bool
-	Create       bool
-	Empty        bool
-	Encrypt      bool
-	Exact        bool
-	Include      *IncludeSet
-	RemoveDir    RelPath
-	Template     bool
+	AutoTemplate    bool
+	Create          bool
+	Empty           bool
+	Encrypt         bool
+	EncryptedSuffix string
+	Exact           bool
+	Include         *IncludeSet
+	RemoveDir       RelPath
+	Template        bool
 }
 
 // Add adds destAbsPathInfos to s.
@@ -374,6 +375,11 @@ func (s *SourceState) Apply(targetSystem, destSystem System, persistentState Per
 	return persistentStateSet(persistentState, entryStateBucket, []byte(targetAbsPath), targetEntryState)
 }
 
+// Encryption returns s's encryption.
+func (s *SourceState) Encryption() Encryption {
+	return s.encryption
+}
+
 // Entries returns s's source state entries.
 func (s *SourceState) Entries() map[RelPath]SourceStateEntry {
 	return s.entries
@@ -490,7 +496,7 @@ func (s *SourceState) Read() error {
 					n++
 				}
 			}
-			targetParentRelPath := parentSourceRelPath.TargetRelPath()
+			targetParentRelPath := parentSourceRelPath.TargetRelPath(s.encryption.EncryptedSuffix())
 			matches = matches[:n]
 			for _, match := range matches {
 				targetRelPath := targetParentRelPath.Join(RelPath(match))
@@ -516,7 +522,7 @@ func (s *SourceState) Read() error {
 			return nil
 		case info.IsDir():
 			da := parseDirAttr(sourceName.String())
-			targetRelPath := parentSourceRelPath.Dir().TargetRelPath().Join(RelPath(da.TargetName))
+			targetRelPath := parentSourceRelPath.Dir().TargetRelPath(s.encryption.EncryptedSuffix()).Join(RelPath(da.TargetName))
 			if s.Ignored(targetRelPath) {
 				return vfs.SkipDir
 			}
@@ -524,8 +530,8 @@ func (s *SourceState) Read() error {
 			allSourceStateEntries[targetRelPath] = append(allSourceStateEntries[targetRelPath], sourceStateEntry)
 			return nil
 		case info.Mode().IsRegular():
-			fa := parseFileAttr(sourceName.String())
-			targetRelPath := parentSourceRelPath.Dir().TargetRelPath().Join(RelPath(fa.TargetName))
+			fa := parseFileAttr(sourceName.String(), s.encryption.EncryptedSuffix())
+			targetRelPath := parentSourceRelPath.Dir().TargetRelPath(s.encryption.EncryptedSuffix()).Join(RelPath(fa.TargetName))
 			if s.Ignored(targetRelPath) {
 				return nil
 			}
@@ -663,7 +669,7 @@ func (s *SourceState) addPatterns(patternSet *patternSet, sourceAbsPath AbsPath,
 	if err != nil {
 		return err
 	}
-	dir := sourceRelPath.Dir().TargetRelPath()
+	dir := sourceRelPath.Dir().TargetRelPath("")
 	scanner := bufio.NewScanner(bytes.NewReader(data))
 	var lineNumber int
 	for scanner.Scan() {
@@ -975,9 +981,10 @@ func (s *SourceState) sourceStateEntry(actualStateEntry ActualStateEntry, destAb
 			Exact:      options.Exact,
 			Private:    isPrivate(info),
 		}
+		sourceRelPath := parentSourceRelPath.Join(NewSourceRelDirPath(RelPath(dirAttr.SourceName())))
 		return &SourceStateDir{
 			Attr:          dirAttr,
-			sourceRelPath: parentSourceRelPath.Join(NewSourceRelDirPath(RelPath(dirAttr.SourceName()))),
+			sourceRelPath: sourceRelPath,
 			targetStateEntry: &TargetStateDir{
 				perm: 0o777,
 			},
@@ -1015,9 +1022,10 @@ func (s *SourceState) sourceStateEntry(actualStateEntry ActualStateEntry, destAb
 		lazyContents := &lazyContents{
 			contents: contents,
 		}
+		sourceRelPath := parentSourceRelPath.Join(NewSourceRelPath(RelPath(fileAttr.SourceName(s.encryption.EncryptedSuffix()))))
 		return &SourceStateFile{
 			Attr:          fileAttr,
-			sourceRelPath: parentSourceRelPath.Join(NewSourceRelPath(RelPath(fileAttr.SourceName()))),
+			sourceRelPath: sourceRelPath,
 			lazyContents:  lazyContents,
 			targetStateEntry: &TargetStateFile{
 				lazyContents: lazyContents,
@@ -1042,9 +1050,10 @@ func (s *SourceState) sourceStateEntry(actualStateEntry ActualStateEntry, destAb
 		lazyContents := &lazyContents{
 			contents: contents,
 		}
+		sourceRelPath := parentSourceRelPath.Join(NewSourceRelPath(RelPath(fileAttr.SourceName(s.encryption.EncryptedSuffix()))))
 		return &SourceStateFile{
 			Attr:          fileAttr,
-			sourceRelPath: parentSourceRelPath.Join(NewSourceRelPath(RelPath(fileAttr.SourceName()))),
+			sourceRelPath: sourceRelPath,
 			lazyContents:  lazyContents,
 			targetStateEntry: &TargetStateFile{
 				lazyContents: lazyContents,


Test patch:
diff --git a/chezmoi2/internal/chezmoi/attr_test.go b/chezmoi2/internal/chezmoi/attr_test.go
index 734fb478d..a3bf469ec 100644
--- a/chezmoi2/internal/chezmoi/attr_test.go
+++ b/chezmoi2/internal/chezmoi/attr_test.go
@@ -125,9 +125,32 @@ func TestFileAttr(t *testing.T) {
 		},
 	}))
 	for _, fa := range fas {
-		actualSourceName := fa.SourceName()
-		actualFA := parseFileAttr(actualSourceName)
+		actualSourceName := fa.SourceName("")
+		actualFA := parseFileAttr(actualSourceName, "")
 		assert.Equal(t, fa, actualFA)
-		assert.Equal(t, actualSourceName, actualFA.SourceName())
+		assert.Equal(t, actualSourceName, actualFA.SourceName(""))
+	}
+}
+
+func TestFileAttrEncryptedSuffix(t *testing.T) {
+	for _, tc := range []struct {
+		sourceName         string
+		expectedTargetName string
+	}{
+		{
+			sourceName:         "encrypted_file",
+			expectedTargetName: "file",
+		},
+		{
+			sourceName:         "encrypted_file.asc",
+			expectedTargetName: "file",
+		},
+		{
+			sourceName:         "file.asc",
+			expectedTargetName: "file.asc",
+		},
+	} {
+		fa := parseFileAttr(tc.sourceName, ".asc")
+		assert.Equal(t, tc.expectedTargetName, fa.TargetName)
 	}
 }
diff --git a/chezmoi2/internal/chezmoi/encryption_test.go b/chezmoi2/internal/chezmoi/encryption_test.go
index 97acf0050..02299e2be 100644
--- a/chezmoi2/internal/chezmoi/encryption_test.go
+++ b/chezmoi2/internal/chezmoi/encryption_test.go
@@ -17,30 +17,34 @@ type xorEncryption struct {
 
 var _ Encryption = &xorEncryption{}
 
-func (t *xorEncryption) Decrypt(ciphertext []byte) ([]byte, error) {
-	return t.xorWithKey(ciphertext), nil
+func (e *xorEncryption) Decrypt(ciphertext []byte) ([]byte, error) {
+	return e.xorWithKey(ciphertext), nil
 }
 
-func (t *xorEncryption) DecryptToFile(filename string, ciphertext []byte) error {
-	return ioutil.WriteFile(filename, t.xorWithKey(ciphertext), 0o666)
+func (e *xorEncryption) DecryptToFile(filename string, ciphertext []byte) error {
+	return ioutil.WriteFile(filename, e.xorWithKey(ciphertext), 0o666)
 }
 
-func (t *xorEncryption) Encrypt(plaintext []byte) ([]byte, error) {
-	return t.xorWithKey(plaintext), nil
+func (e *xorEncryption) Encrypt(plaintext []byte) ([]byte, error) {
+	return e.xorWithKey(plaintext), nil
 }
 
-func (t *xorEncryption) EncryptFile(filename string) ([]byte, error) {
+func (e *xorEncryption) EncryptFile(filename string) ([]byte, error) {
 	plaintext, err := ioutil.ReadFile(filename)
 	if err != nil {
 		return nil, err
 	}
-	return t.xorWithKey(plaintext), nil
+	return e.xorWithKey(plaintext), nil
 }
 
-func (t *xorEncryption) xorWithKey(input []byte) []byte {
+func (e *xorEncryption) EncryptedSuffix() string {
+	return ".xor"
+}
+
+func (e *xorEncryption) xorWithKey(input []byte) []byte {
 	output := make([]byte, 0, len(input))
 	for _, b := range input {
-		output = append(output, b^t.key)
+		output = append(output, b^e.key)
 	}
 	return output
 }
diff --git a/chezmoi2/internal/chezmoi/sourcerelpath_test.go b/chezmoi2/internal/chezmoi/sourcerelpath_test.go
index 7c6feb06f..380d534e9 100644
--- a/chezmoi2/internal/chezmoi/sourcerelpath_test.go
+++ b/chezmoi2/internal/chezmoi/sourcerelpath_test.go
@@ -57,7 +57,7 @@ func TestSourceRelPath(t *testing.T) {
 	} {
 		t.Run(tc.name, func(t *testing.T) {
 			assert.Equal(t, tc.expectedDirPath, tc.sourceStatePath.Dir())
-			assert.Equal(t, tc.expectedTargetRelPath, tc.sourceStatePath.TargetRelPath())
+			assert.Equal(t, tc.expectedTargetRelPath, tc.sourceStatePath.TargetRelPath(""))
 		})
 	}
 }
diff --git a/chezmoi2/main_test.go b/chezmoi2/main_test.go
index 8e40cafeb..9cfdc81d1 100644
--- a/chezmoi2/main_test.go
+++ b/chezmoi2/main_test.go
@@ -40,6 +40,7 @@ func TestScript(t *testing.T) {
 	testscript.Run(t, testscript.Params{
 		Dir: filepath.Join("testdata", "scripts"),
 		Cmds: map[string]func(*testscript.TestScript, bool, []string){
+			"appendline":     cmdAppendLine,
 			"chhome":         cmdChHome,
 			"cmpmod":         cmdCmpMod,
 			"edit":           cmdEdit,
@@ -71,6 +72,21 @@ func TestScript(t *testing.T) {
 	})
 }
 
+// cmdAppendLine appends lines to a file.
+func cmdAppendLine(ts *testscript.TestScript, neg bool, args []string) {
+	if neg {
+		ts.Fatalf("unsupported: ! appendline")
+	}
+	if len(args) != 2 {
+		ts.Fatalf("usage: appendline file line")
+	}
+	filename := ts.MkAbs(args[0])
+	data, err := ioutil.ReadFile(filename)
+	ts.Check(err)
+	data = append(data, append([]byte(args[1]), '\n')...)
+	ts.Check(ioutil.WriteFile(filename, data, 0o666))
+}
+
 // cmdChHome changes the home directory to its argument, creating the directory
 // if it does not already exist. It updates the HOME environment variable, and,
 // if running on Windows, USERPROFILE too.
diff --git a/chezmoi2/testdata/scripts/age.txt b/chezmoi2/testdata/scripts/age.txt
index 2a353b2c5..fd5f9ca93 100644
--- a/chezmoi2/testdata/scripts/age.txt
+++ b/chezmoi2/testdata/scripts/age.txt
@@ -6,8 +6,8 @@ mkageconfig
 # test that chezmoi add --encrypt encrypts
 cp golden/.encrypted $HOME
 chezmoi add --encrypt $HOME${/}.encrypted
-exists $CHEZMOISOURCEDIR/encrypted_dot_encrypted
-! grep plaintext $CHEZMOISOURCEDIR/encrypted_dot_encrypted
+exists $CHEZMOISOURCEDIR/encrypted_dot_encrypted.age
+! grep plaintext $CHEZMOISOURCEDIR/encrypted_dot_encrypted.age
 
 # test that chezmoi apply decrypts
 rm $HOME/.encrypted
diff --git a/chezmoi2/testdata/scripts/gpg.txt b/chezmoi2/testdata/scripts/gpg.txt
index 25f13fdc9..7b5cf1ac4 100644
--- a/chezmoi2/testdata/scripts/gpg.txt
+++ b/chezmoi2/testdata/scripts/gpg.txt
@@ -7,8 +7,8 @@ mkgpgconfig
 # test that chezmoi add --encrypt encrypts
 cp golden/.encrypted $HOME
 chezmoi add --encrypt $HOME${/}.encrypted
-exists $CHEZMOISOURCEDIR/encrypted_dot_encrypted
-! grep plaintext $CHEZMOISOURCEDIR/encrypted_dot_encrypted
+exists $CHEZMOISOURCEDIR/encrypted_dot_encrypted.asc
+! grep plaintext $CHEZMOISOURCEDIR/encrypted_dot_encrypted.asc
 
 # test that chezmoi apply decrypts
 rm $HOME/.encrypted
diff --git a/chezmoi2/testdata/scripts/gpgencryption.txt b/chezmoi2/testdata/scripts/gpgencryption.txt
new file mode 100644
index 000000000..26ec25bad
--- /dev/null
+++ b/chezmoi2/testdata/scripts/gpgencryption.txt
@@ -0,0 +1,12 @@
+[!exec:gpg] stop
+[githubactionsonwindows] skip 'gpg is broken in GitHub Actions on Windows'
+
+mkgpgconfig
+appendline $CHEZMOICONFIGDIR/chezmoi.toml '  suffix = ".gpg"'
+
+# test that chezmoi add --encrypted adds a file with the expected suffix to the source dir
+chezmoi add --encrypt $HOME${/}.file
+exists $CHEZMOISOURCEDIR/encrypted_dot_file.gpg
+
+-- home/user/.file --
+# contents of .file