diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index c551cecbffe..4f0c773ae98 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -304,7 +304,9 @@ The following configuration variables are available: | `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 | -| `diff` | `exclude` | []string | *none* | Entry types to exclude from diff | +| `diff` | `args` | []string | *see `diff` below* | Extra args to external diff command | +| | `command` | string | *none* | External diff command | +| | `exclude` | []string | *none* | Entry types to exclude from diff | | | `pager` | string | *none* | Diff-specific pager | | `docs` | `maxWidth` | int | 80 | Maximum width of output | | | `pager` | string | *none* | Docs-specific pager | @@ -877,10 +879,22 @@ Print the difference between the target state and the destination state for If a `diff.pager` command is set in the configuration file then the output will be piped into it. +If `diff.command` is set then it will be invoked to show individual file +differences with `diff.args` passed as arguments. Each element of `diff.args` is +interpreted as a template with the variables `.Destination` and `.Target` +available corresponding to the path of the file in the source and target state +respectively. The default value of `diff.args` is `["{{ .Destination }}", +"{{ .Target }}"]`. + #### `--pager` *pager* Pager to use for output. +#### `--use-builtin-diff` + +Use chezmoi's builtin diff, even if the `diff.command` configuration variable is +set. + #### `diff` examples ```console diff --git a/internal/chezmoi/externaldiffsystem.go b/internal/chezmoi/externaldiffsystem.go new file mode 100644 index 00000000000..adfed648e8a --- /dev/null +++ b/internal/chezmoi/externaldiffsystem.go @@ -0,0 +1,197 @@ +package chezmoi + +import ( + "errors" + "io/fs" + "os" + "os/exec" + "strconv" + "strings" + "text/template" + + vfs "github.com/twpayne/go-vfs/v3" +) + +// An ExternalDiffSystem is a DiffSystem that uses an external diff tool. +type ExternalDiffSystem struct { + system System + command string + args []string + destDirAbsPath AbsPath + tempDirAbsPath AbsPath +} + +// NewExternalDiffSystem creates a new ExternalDiffSystem. +func NewExternalDiffSystem(system System, command string, args []string, destDirAbsPath AbsPath) *ExternalDiffSystem { + return &ExternalDiffSystem{ + system: system, + command: command, + args: args, + destDirAbsPath: destDirAbsPath, + } +} + +// Close frees all resources held by s. +func (s *ExternalDiffSystem) Close() error { + if s.tempDirAbsPath != "" { + if err := os.RemoveAll(string(s.tempDirAbsPath)); err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } + s.tempDirAbsPath = "" + } + return nil +} + +// Chmod implements System.Chmod. +func (s *ExternalDiffSystem) Chmod(name AbsPath, mode fs.FileMode) error { + return s.system.Chmod(name, mode) +} + +// Glob implements System.Glob. +func (s *ExternalDiffSystem) Glob(pattern string) ([]string, error) { + return s.system.Glob(pattern) +} + +// IdempotentCmdCombinedOutput implements System.IdempotentCmdCombinedOutput. +func (s *ExternalDiffSystem) IdempotentCmdCombinedOutput(cmd *exec.Cmd) ([]byte, error) { + return s.system.IdempotentCmdCombinedOutput(cmd) +} + +// IdempotentCmdOutput implements System.IdempotentCmdOutput. +func (s *ExternalDiffSystem) IdempotentCmdOutput(cmd *exec.Cmd) ([]byte, error) { + return s.system.IdempotentCmdOutput(cmd) +} + +// Lstat implements System.Lstat. +func (s *ExternalDiffSystem) Lstat(name AbsPath) (fs.FileInfo, error) { + return s.system.Lstat(name) +} + +// Mkdir implements System.Mkdir. +func (s *ExternalDiffSystem) Mkdir(name AbsPath, perm fs.FileMode) error { + return s.system.Mkdir(name, perm) +} + +// RawPath implements System.RawPath. +func (s *ExternalDiffSystem) RawPath(path AbsPath) (AbsPath, error) { + return s.system.RawPath(path) +} + +// ReadDir implements System.ReadDir. +func (s *ExternalDiffSystem) ReadDir(name AbsPath) ([]fs.DirEntry, error) { + return s.system.ReadDir(name) +} + +// ReadFile implements System.ReadFile. +func (s *ExternalDiffSystem) ReadFile(name AbsPath) ([]byte, error) { + return s.system.ReadFile(name) +} + +// Readlink implements System.Readlink. +func (s *ExternalDiffSystem) Readlink(name AbsPath) (string, error) { + return s.system.Readlink(name) +} + +// RemoveAll implements System.RemoveAll. +func (s *ExternalDiffSystem) RemoveAll(name AbsPath) error { + // FIXME generate suitable inputs for s.command + return s.system.RemoveAll(name) +} + +// Rename implements System.Rename. +func (s *ExternalDiffSystem) Rename(oldpath, newpath AbsPath) error { + // FIXME generate suitable inputs for s.command + return s.system.Rename(oldpath, newpath) +} + +// RunCmd implements System.RunCmd. +func (s *ExternalDiffSystem) RunCmd(cmd *exec.Cmd) error { + return s.system.RunCmd(cmd) +} + +// RunIdempotentCmd implements System.RunIdempotentCmd. +func (s *ExternalDiffSystem) RunIdempotentCmd(cmd *exec.Cmd) error { + return s.system.RunIdempotentCmd(cmd) +} + +// RunScript implements System.RunScript. +func (s *ExternalDiffSystem) RunScript(scriptname RelPath, dir AbsPath, data []byte, interpreter *Interpreter) error { + // FIXME generate suitable inputs for s.command + return s.system.RunScript(scriptname, dir, data, interpreter) +} + +// Stat implements System.Stat. +func (s *ExternalDiffSystem) Stat(name AbsPath) (fs.FileInfo, error) { + return s.system.Stat(name) +} + +// UnderlyingFS implements System.UnderlyingFS. +func (s *ExternalDiffSystem) UnderlyingFS() vfs.FS { + return s.system.UnderlyingFS() +} + +// WriteFile implements System.WriteFile. +func (s *ExternalDiffSystem) WriteFile(filename AbsPath, data []byte, perm fs.FileMode) error { + targetRelPath, err := filename.TrimDirPrefix(s.destDirAbsPath) + if err != nil { + return err + } + tempDirAbsPath, err := s.tempDir() + if err != nil { + return err + } + targetAbsPath := tempDirAbsPath.Join(targetRelPath) + if err := os.WriteFile(string(targetAbsPath), data, perm); err != nil { + return err + } + return s.runDiffCommand(filename, targetAbsPath) +} + +// WriteSymlink implements System.WriteSymlink. +func (s *ExternalDiffSystem) WriteSymlink(oldname string, newname AbsPath) error { + // FIXME generate suitable inputs for s.command + return s.system.WriteSymlink(oldname, newname) +} + +// tempDir creates a temporary directory for s if it does not already exist and +// returns its path. +func (s *ExternalDiffSystem) tempDir() (AbsPath, error) { + if s.tempDirAbsPath == "" { + tempDir, err := os.MkdirTemp("", "chezmoi-diff") + if err != nil { + return "", err + } + s.tempDirAbsPath = AbsPath(tempDir) + } + return s.tempDirAbsPath, nil +} + +// runDiffCommand runs the external diff command. +func (s *ExternalDiffSystem) runDiffCommand(destAbsPath, targetAbsPath AbsPath) error { + templateData := struct { + Destination string + Target string + }{ + Destination: string(destAbsPath), + Target: string(targetAbsPath), + } + args := make([]string, 0, len(s.args)) + for i, arg := range s.args { + tmpl, err := template.New("diff.args[" + strconv.Itoa(i) + "]").Parse(arg) + if err != nil { + return err + } + var sb strings.Builder + if err := tmpl.Execute(&sb, templateData); err != nil { + return err + } + args = append(args, sb.String()) + } + + //nolint:gosec + cmd := exec.Command(s.command, args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return s.system.RunIdempotentCmd(cmd) +} diff --git a/internal/cmd/config.go b/internal/cmd/config.go index 01ff8f226fa..6eef800e681 100644 --- a/internal/cmd/config.go +++ b/internal/cmd/config.go @@ -256,6 +256,10 @@ func newConfig(options ...configOption) (*Config, error) { recursive: true, }, Diff: diffCmdConfig{ + Args: []string{ + "{{ .Destination }}", + "{{ .Target }}", + }, Exclude: chezmoi.NewEntryTypeSet(chezmoi.EntryTypesNone), include: chezmoi.NewEntryTypeSet(chezmoi.EntryTypesAll), }, diff --git a/internal/cmd/diffcmd.go b/internal/cmd/diffcmd.go index 860bf494be8..6af568bf250 100644 --- a/internal/cmd/diffcmd.go +++ b/internal/cmd/diffcmd.go @@ -9,10 +9,13 @@ import ( ) type diffCmdConfig struct { - Exclude *chezmoi.EntryTypeSet `mapstructure:"exclude"` - Pager string `mapstructure:"pager"` - include *chezmoi.EntryTypeSet - recursive bool + Command string `mapstructure:"command"` + Args []string `mapstructure:"args"` + Exclude *chezmoi.EntryTypeSet `mapstructure:"exclude"` + Pager string `mapstructure:"pager"` + include *chezmoi.EntryTypeSet + recursive bool + useBuiltinDiff bool } func (c *Config) newDiffCmd() *cobra.Command { @@ -32,6 +35,7 @@ func (c *Config) newDiffCmd() *cobra.Command { flags.VarP(c.Diff.include, "include", "i", "Include entry types") flags.BoolVarP(&c.Diff.recursive, "recursive", "r", c.Diff.recursive, "Recurse into subdirectories") flags.StringVar(&c.Diff.Pager, "pager", c.Diff.Pager, "Set pager") + flags.BoolVarP(&c.Diff.useBuiltinDiff, "use-builtin-diff", "", c.Diff.useBuiltinDiff, "Use the builtin diff") return diffCmd } @@ -43,13 +47,22 @@ func (c *Config) runDiffCmd(cmd *cobra.Command, args []string) error { if err != nil { return err } - gitDiffSystem := chezmoi.NewGitDiffSystem(dryRunSystem, &sb, c.DestDirAbsPath, color) - if err := c.applyArgs(gitDiffSystem, c.DestDirAbsPath, args, applyArgsOptions{ + if c.Diff.useBuiltinDiff || c.Diff.Command == "" { + gitDiffSystem := chezmoi.NewGitDiffSystem(dryRunSystem, &sb, c.DestDirAbsPath, color) + if err := c.applyArgs(gitDiffSystem, c.DestDirAbsPath, args, applyArgsOptions{ + include: c.Diff.include.Sub(c.Diff.Exclude), + recursive: c.Diff.recursive, + umask: c.Umask, + }); err != nil { + return err + } + return c.pageOutputString(sb.String(), c.Diff.Pager) + } + diffSystem := chezmoi.NewExternalDiffSystem(dryRunSystem, c.Diff.Command, c.Diff.Args, c.DestDirAbsPath) + defer diffSystem.Close() + return c.applyArgs(diffSystem, c.DestDirAbsPath, args, applyArgsOptions{ include: c.Diff.include.Sub(c.Diff.Exclude), recursive: c.Diff.recursive, umask: c.Umask, - }); err != nil { - return err - } - return c.pageOutputString(sb.String(), c.Diff.Pager) + }) } diff --git a/internal/cmd/testdata/scripts/diff_unix.txt b/internal/cmd/testdata/scripts/diff_unix.txt new file mode 100644 index 00000000000..52f39b4e332 --- /dev/null +++ b/internal/cmd/testdata/scripts/diff_unix.txt @@ -0,0 +1,31 @@ +[windows] skip 'UNIX only' + +chmod 755 bin/vimdiff + +# test that chezmoi diff invokes diff.command when configured +chezmoi diff +stdout 'vimdiff .*/home/user/\.file .*/chezmoi-diff.*/\.file' # FIXME should be able to use ${HOME@R} in this regexp + +# test that chezmoi diff --use-builtin-diff uses the builtin diff even if diff.command is configured +chezmoi diff --use-builtin-diff +cmp stdout golden/diff + +-- bin/vimdiff -- +#!/bin/sh + +echo vimdiff "$*" +-- golden/diff -- +diff --git a/.file b/.file +index bd729e8ee3cc005444c67dc77eed60016886b5e0..b508963510528ab709627ec448026a10a64c72ef 100644 +--- a/.file ++++ b/.file +@@ -1 +1 @@ +-# destination contents of .file ++# target contents of .file +-- home/user/.config/chezmoi/chezmoi.toml -- +[diff] + command = "vimdiff" +-- home/user/.file -- +# destination contents of .file +-- home/user/.local/share/chezmoi/dot_file -- +# target contents of .file