diff --git a/internal/chezmoi/chezmoi.go b/internal/chezmoi/chezmoi.go index 7fb2f7edaf0..75b55c6b177 100644 --- a/internal/chezmoi/chezmoi.go +++ b/internal/chezmoi/chezmoi.go @@ -131,6 +131,11 @@ var FileModeTypeNames = map[fs.FileMode]string{ fs.ModeCharDevice: "char device", } +// A TextConvFunc converts the contents of a file into a more human-readable +// form. It returns the converted data, whether any conversion occurred, and any +// error. +type TextConvFunc func(string, []byte) ([]byte, bool, error) + // FQDNHostname returns the FQDN hostname. func FQDNHostname(fileSystem vfs.FS) (string, error) { // First, try os.Hostname. If it returns something that looks like a FQDN diff --git a/internal/chezmoi/externaldiffsystem.go b/internal/chezmoi/externaldiffsystem.go index 9e92b74f79d..8f7410f6f12 100644 --- a/internal/chezmoi/externaldiffsystem.go +++ b/internal/chezmoi/externaldiffsystem.go @@ -27,6 +27,7 @@ type ExternalDiffSystem struct { filter *EntryTypeFilter reverse bool scriptContents bool + textConvFunc TextConvFunc } // ExternalDiffSystemOptions are options for NewExternalDiffSystem. @@ -34,6 +35,7 @@ type ExternalDiffSystemOptions struct { Filter *EntryTypeFilter Reverse bool ScriptContents bool + TextConvFunc TextConvFunc } // NewExternalDiffSystem creates a new ExternalDiffSystem. @@ -52,6 +54,7 @@ func NewExternalDiffSystem( filter: options.Filter, reverse: options.Reverse, scriptContents: options.ScriptContents, + textConvFunc: options.TextConvFunc, } } @@ -222,34 +225,68 @@ func (s *ExternalDiffSystem) UnderlyingFS() vfs.FS { // WriteFile implements System.WriteFile. func (s *ExternalDiffSystem) WriteFile(filename AbsPath, data []byte, perm fs.FileMode) error { if s.filter.IncludeEntryTypeBits(EntryTypeFiles) { + targetRelPath, err := filename.TrimDirPrefix(s.destDirAbsPath) + if err != nil { + return err + } + tempDirAbsPath, err := s.tempDir() + if err != nil { + return err + } + // If filename does not exist, replace it with /dev/null to avoid // passing the name of a non-existent file to the external diff command. - destAbsPath := filename - switch _, err := os.Stat(destAbsPath.String()); { + // Otherwise, if the file exists and a textconv filter is configured, + // run the filter and update fromAbsPath to point to the converted data. + fromAbsPath := filename + switch fileInfo, err := os.Lstat(fromAbsPath.String()); { case errors.Is(err, fs.ErrNotExist): - destAbsPath = devNullAbsPath + fromAbsPath = devNullAbsPath case err != nil: return err + case s.textConvFunc != nil: + // Maybe convert the from data with textconv. + fromData, err := os.ReadFile(fromAbsPath.String()) + if err != nil { + return err + } + switch convertedFromData, converted, err := s.textConvFunc(fromAbsPath.String(), fromData); { + case err != nil: + return err + case converted: + tempFromAbsPath := tempDirAbsPath.Join(NewRelPath("a"), targetRelPath) + if err := os.MkdirAll(tempFromAbsPath.Dir().String(), 0o700); err != nil { + return err + } + if err := os.WriteFile(tempFromAbsPath.String(), convertedFromData, fileInfo.Mode().Perm()); err != nil { + return err + } + fromAbsPath = tempFromAbsPath + } } // Write the target contents to a file in a temporary directory. - targetRelPath, err := filename.TrimDirPrefix(s.destDirAbsPath) - if err != nil { - return err - } - tempDirAbsPath, err := s.tempDir() - if err != nil { - return err + toAbsPath := tempDirAbsPath.Join(targetRelPath) + toData := data + if s.textConvFunc != nil { + // Maybe convert the to data with textconv. + switch convertedToData, converted, err := s.textConvFunc(filename.String(), toData); { + case err != nil: + return err + case converted: + toAbsPath = tempDirAbsPath.Join(NewRelPath("b"), targetRelPath) + toData = convertedToData + } } - targetAbsPath := tempDirAbsPath.Join(targetRelPath) - if err := os.MkdirAll(targetAbsPath.Dir().String(), 0o700); err != nil { + if err := os.MkdirAll(toAbsPath.Dir().String(), 0o700); err != nil { return err } - if err := os.WriteFile(targetAbsPath.String(), data, perm); err != nil { + if err := os.WriteFile(toAbsPath.String(), toData, perm); err != nil { return err } - if err := s.runDiffCommand(destAbsPath, targetAbsPath); err != nil { + // Run the external diff command. + if err := s.runDiffCommand(fromAbsPath, toAbsPath); err != nil { return err } } diff --git a/internal/chezmoi/gitdiffsystem.go b/internal/chezmoi/gitdiffsystem.go index 7189ac80e93..047af3ee835 100644 --- a/internal/chezmoi/gitdiffsystem.go +++ b/internal/chezmoi/gitdiffsystem.go @@ -14,9 +14,6 @@ import ( vfs "github.com/twpayne/go-vfs/v5" ) -// A TextConvFunc converts the contents of a file into a more human-readable form. -type TextConvFunc func(string, []byte) ([]byte, error) - // A GitDiffSystem wraps a System and logs all of the actions executed as a git // diff. type GitDiffSystem struct { @@ -278,7 +275,7 @@ func (s *GitDiffSystem) encodeDiff(absPath AbsPath, toData []byte, toMode fs.Fil return err } if s.textConvFunc != nil { - fromData, err = s.textConvFunc(absPath.String(), fromData) + fromData, _, err = s.textConvFunc(absPath.String(), fromData) if err != nil { return err } @@ -297,7 +294,7 @@ func (s *GitDiffSystem) encodeDiff(absPath AbsPath, toData []byte, toMode fs.Fil if s.textConvFunc != nil { var err error - toData, err = s.textConvFunc(absPath.String(), toData) + toData, _, err = s.textConvFunc(absPath.String(), toData) if err != nil { return err } diff --git a/internal/cmd/config.go b/internal/cmd/config.go index eda605af996..1302a281c4c 100644 --- a/internal/cmd/config.go +++ b/internal/cmd/config.go @@ -1228,14 +1228,14 @@ func (c *Config) diffFile( } if fromMode.IsRegular() { var err error - fromData, err = c.TextConv.convert(path.String(), fromData) + fromData, _, err = c.TextConv.convert(path.String(), fromData) if err != nil { return err } } if toMode.IsRegular() { var err error - toData, err = c.TextConv.convert(path.String(), toData) + toData, _, err = c.TextConv.convert(path.String(), toData) if err != nil { return err } @@ -1817,6 +1817,7 @@ func (c *Config) newDiffSystem(s chezmoi.System, w io.Writer, dirAbsPath chezmoi Filter: chezmoi.NewEntryTypeFilter(c.Diff.include.Bits(), c.Diff.Exclude.Bits()), Reverse: c.Diff.Reverse, ScriptContents: c.Diff.ScriptContents, + TextConvFunc: c.TextConv.convert, } return chezmoi.NewExternalDiffSystem(s, c.Diff.Command, c.Diff.Args, c.DestDirAbsPath, options) } diff --git a/internal/cmd/testdata/scripts/externaldiff.txtar b/internal/cmd/testdata/scripts/externaldiff.txtar index fe6b83163f7..3c743aeec0d 100644 --- a/internal/cmd/testdata/scripts/externaldiff.txtar +++ b/internal/cmd/testdata/scripts/externaldiff.txtar @@ -1,5 +1,7 @@ [windows] skip 'UNIX only' +chmod 755 bin/external-diff + # test that chezmoi diff invokes the external diff command for scripts exec chezmoi diff stdout '# contents of script' @@ -20,6 +22,26 @@ chhome home3/user exec chezmoi diff stdout ^/dev/null\s${WORK@R}/.*/\.dir$ +chhome home4/user + +# test that chezmoi diff uses textconv when an external diff tool is used +exec chezmoi diff +cmp stdout golden/external-diff + +-- bin/external-diff -- +#!/bin/sh + +echo old/$(basename $1): +cat $1 +echo +echo new/$(basename $2): +cat $2 +-- golden/external-diff -- +old/file.txt: +# OLD CONTENTS OF .DIR/FILE.TXT + +new/file.txt: +# NEW CONTENTS OF .DIR/FILE.TXT -- home/user/.config/chezmoi/chezmoi.toml -- [diff] command = "cat" @@ -34,3 +56,16 @@ diff: [diff] command = "echo" -- home3/user/.local/share/chezmoi/dot_dir/.keep -- +-- home4/user/.config/chezmoi/chezmoi.yaml -- +diff: + command: external-diff +textconv: +- pattern: '**/*.txt' + command: tr + args: + - a-z + - A-Z +-- home4/user/.dir/file.txt -- +# old contents of .dir/file.txt +-- home4/user/.local/share/chezmoi/dot_dir/file.txt -- +# new contents of .dir/file.txt diff --git a/internal/cmd/textconv.go b/internal/cmd/textconv.go index 2d1a102fe05..a7db3c67bba 100644 --- a/internal/cmd/textconv.go +++ b/internal/cmd/textconv.go @@ -19,12 +19,12 @@ type textConvElement struct { type textConv []*textConvElement -func (t textConv) convert(path string, data []byte) ([]byte, error) { +func (t textConv) convert(path string, data []byte) ([]byte, bool, error) { var longestPatternElement *textConvElement for _, command := range t { ok, err := doublestar.Match(command.Pattern, path) if err != nil { - return nil, err + return nil, false, err } if !ok { continue @@ -34,11 +34,15 @@ func (t textConv) convert(path string, data []byte) ([]byte, error) { } } if longestPatternElement == nil { - return data, nil + return data, false, nil } cmd := exec.Command(longestPatternElement.Command, longestPatternElement.Args...) cmd.Stdin = bytes.NewReader(data) cmd.Stderr = os.Stderr - return chezmoilog.LogCmdOutput(slog.Default(), cmd) + convertedData, err := chezmoilog.LogCmdOutput(slog.Default(), cmd) + if err != nil { + return nil, false, err + } + return convertedData, true, nil }