package chezmoi import ( "bytes" "errors" "io/fs" "log/slog" "os" "os/exec" "strconv" "time" vfs "github.com/twpayne/go-vfs/v5" "chezmoi.io/chezmoi/v2/internal/chezmoilog" ) // An ExternalDiffSystem is a DiffSystem that uses an external diff tool. type ExternalDiffSystem struct { system System command string args []string destDirAbsPath AbsPath tempDirAbsPath AbsPath filter *EntryTypeFilter pagerCmdFunc func() (*exec.Cmd, error) reverse bool scriptContents bool templateOptions TemplateOptions textConvFunc TextConvFunc } // ExternalDiffSystemOptions are options for NewExternalDiffSystem. type ExternalDiffSystemOptions struct { Filter *EntryTypeFilter Reverse bool ScriptContents bool TextConvFunc TextConvFunc TemplateOptions TemplateOptions } // NewExternalDiffSystem creates a new ExternalDiffSystem. func NewExternalDiffSystem( system System, command string, args []string, destDirAbsPath AbsPath, pagerCmdFunc func() (*exec.Cmd, error), options *ExternalDiffSystemOptions, ) *ExternalDiffSystem { return &ExternalDiffSystem{ system: system, command: command, args: args, destDirAbsPath: destDirAbsPath, filter: options.Filter, pagerCmdFunc: pagerCmdFunc, reverse: options.Reverse, scriptContents: options.ScriptContents, templateOptions: options.TemplateOptions, textConvFunc: options.TextConvFunc, } } // Chmod implements System.Chmod. func (s *ExternalDiffSystem) Chmod(name AbsPath, mode fs.FileMode) error { // FIXME generate suitable inputs for s.command return s.system.Chmod(name, mode) } // Chtimes implements System.Chtimes. func (s *ExternalDiffSystem) Chtimes(name AbsPath, atime, mtime time.Time) error { return s.system.Chtimes(name, atime, mtime) } // Close frees all resources held by s. func (s *ExternalDiffSystem) Close() error { if !s.tempDirAbsPath.IsEmpty() { if err := os.RemoveAll(s.tempDirAbsPath.String()); err != nil && !errors.Is(err, fs.ErrNotExist) { return err } s.tempDirAbsPath = EmptyAbsPath } return nil } // Glob implements System.Glob. func (s *ExternalDiffSystem) Glob(pattern string) ([]string, error) { return s.system.Glob(pattern) } // Link implements System.Link. func (s *ExternalDiffSystem) Link(oldName, newName AbsPath) error { // FIXME generate suitable inputs for s.command return s.system.Link(oldName, newName) } // 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 { if s.filter.IncludeEntryTypeBits(EntryTypeDirs) { targetRelPath, err := name.TrimDirPrefix(s.destDirAbsPath) if err != nil { return err } tempDirAbsPath, err := s.tempDir() if err != nil { return err } targetAbsPath := tempDirAbsPath.Join(targetRelPath) if err := os.MkdirAll(targetAbsPath.String(), perm); err != nil { return err } if err := s.RunDiffCommand(devNullAbsPath, targetAbsPath); err != nil { return err } } 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) } // Remove implements System.Remove. func (s *ExternalDiffSystem) Remove(name AbsPath) error { if s.filter.IncludeEntryTypeBits(EntryTypeRemove) { switch fileInfo, err := s.system.Lstat(name); { case errors.Is(err, fs.ErrNotExist): // Do nothing. case err != nil: return err case s.filter.IncludeFileInfo(fileInfo): if err := s.RunDiffCommand(name, devNullAbsPath); err != nil { return err } } } return s.system.Remove(name) } // RemoveAll implements System.RemoveAll. func (s *ExternalDiffSystem) RemoveAll(name AbsPath) error { if s.filter.IncludeEntryTypeBits(EntryTypeRemove) { switch fileInfo, err := s.system.Lstat(name); { case errors.Is(err, fs.ErrNotExist): // Do nothing. case err != nil: return err case s.filter.IncludeFileInfo(fileInfo): if err := s.RunDiffCommand(name, devNullAbsPath); err != nil { return err } } } 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) } // RunDiffCommand runs the external diff command. func (s *ExternalDiffSystem) RunDiffCommand(destAbsPath, targetAbsPath AbsPath) error { templateData := struct { Destination string Target string }{ Destination: destAbsPath.String(), Target: targetAbsPath.String(), } if s.reverse { templateData.Destination, templateData.Target = templateData.Target, templateData.Destination } args := make([]string, 0, len(s.args)) // Work around a regression introduced in 2.1.5 // (https://github.com/twpayne/chezmoi/pull/1328) in a user-friendly way. // // Prior to #1328, the diff.args config option was prepended to the default // order of files to the diff command. Post #1328, the diff.args config // option replaced all arguments to the diff command. // // Work around this by looking for any templates in diff.args. An arg is // considered a template if, after execution as a template, it is not equal // to the original arg. anyTemplateArgs := false for i, arg := range s.args { tmpl, err := ParseTemplate("diff.args["+strconv.Itoa(i)+"]", []byte(arg), s.templateOptions) if err != nil { return err } newArg, err := tmpl.ExecuteString(templateData) if err != nil { return err } args = append(args, newArg) // Detect template arguments. if arg != newArg { anyTemplateArgs = true } } // If there are no template arguments, then append the destination and // target paths as prior to #1328. if !anyTemplateArgs { args = append(args, templateData.Destination, templateData.Target) } diffCmd := exec.Command(s.command, args...) diffCmd.Stdin = os.Stdin diffCmd.Stderr = os.Stderr var diffCmdErr error switch diffPagerCmd, err := s.pagerCmdFunc(); { case err != nil: return err case diffPagerCmd == nil: diffCmd.Stdout = os.Stdout diffCmdErr = chezmoilog.LogCmdRun(slog.Default(), diffCmd) default: reader, writer, err := os.Pipe() if err != nil { return err } defer func() { _ = writer.Close() }() diffPagerCmd.Stdin = reader if err := chezmoilog.LogCmdStart(slog.Default(), diffPagerCmd); err != nil { return err } diffCmd.Stdout = writer diffCmdErr = chezmoilog.LogCmdRun(slog.Default(), diffCmd) if err := writer.Close(); err != nil { return err } if err := diffPagerCmd.Wait(); err != nil { return err } } // Swallow exit status 1 errors if the entries differ and there are actual // differences between the entries as diff commands traditionally exit with // code 1 in this case. if exitError := (&exec.ExitError{}); errors.As(diffCmdErr, &exitError) && exitError.ExitCode() == 1 { switch entriesDiffer, err := s.entriesDiffer(destAbsPath, targetAbsPath); { case err != nil: return err case entriesDiffer: return nil } } return diffCmdErr } // RunScript implements System.RunScript. func (s *ExternalDiffSystem) RunScript(scriptName RelPath, dir AbsPath, data []byte, options RunScriptOptions) error { bits := EntryTypeScripts if options.Condition == ScriptConditionAlways { bits |= EntryTypeAlways } if s.filter.IncludeEntryTypeBits(bits) { tempDirAbsPath, err := s.tempDir() if err != nil { return err } targetAbsPath := tempDirAbsPath.Join(scriptName) if err := os.MkdirAll(targetAbsPath.Dir().String(), 0o700); err != nil { return err } toData := data if !s.scriptContents { toData = nil } if err := os.WriteFile(targetAbsPath.String(), toData, 0o700); err != nil { return err } if err := s.RunDiffCommand(devNullAbsPath, targetAbsPath); err != nil { return err } } return s.system.RunScript(scriptName, dir, data, options) } // 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 { 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. // 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): 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. 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 } } if err := os.MkdirAll(toAbsPath.Dir().String(), 0o700); err != nil { return err } if err := os.WriteFile(toAbsPath.String(), toData, perm); err != nil { return err } // Run the external diff command. if err := s.RunDiffCommand(fromAbsPath, toAbsPath); err != nil { return err } } return s.system.WriteFile(filename, data, perm) } // 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) } // entriesDiffer returns whether the two given entries differ. // // This function employs negative logic, i.e. that the default is that the // function returns that the entries DO differ unless it can prove otherwise. func (s *ExternalDiffSystem) entriesDiffer(absPath1, absPath2 AbsPath) (bool, error) { fileInfo1, err1 := s.Lstat(absPath1) fileInfo2, err2 := s.Lstat(absPath2) switch { case errors.Is(err1, fs.ErrNotExist) && errors.Is(err2, fs.ErrNotExist): // If neither entry exists, then they do not differ. return false, nil case err1 != nil || err2 != nil: // If lstating either entry returned an error, then combine both errors. return true, errors.Join(err1, err2) case fileInfo1.Mode() != fileInfo2.Mode(): // If the modes are not equal, then the entries are not equal. This // covers the case where the entries are of different types, or that // their permissions differ. return true, nil case fileInfo1.Mode()&fs.ModeType == 0: // If both entries are files, then their contents must be equal. contents1, err1 := s.ReadFile(absPath1) if err1 != nil { return true, err1 } contents2, err2 := s.ReadFile(absPath2) if err2 != nil { return true, err2 } return !bytes.Equal(contents1, contents2), nil case fileInfo1.Mode()&fs.ModeType == fs.ModeSymlink: // If both entries are symlinks, then their targets must be equal. linkname1, err1 := s.Readlink(absPath1) if err1 != nil { return true, err1 } linkname2, err2 := s.Readlink(absPath2) if err2 != nil { return true, err2 } return linkname1 != linkname2, nil default: // Otherwise return that the entries DO differ. This code should not be // reached. This does not cover the case where both entries are special // files (e.g. device nodes with the same device numbers or UNIX domain // sockets) but chezmoi does not manage these types of entries. return true, nil } } // 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.IsEmpty() { tempDir, err := os.MkdirTemp("", "chezmoi-diff") if err != nil { return EmptyAbsPath, err } s.tempDirAbsPath = NewAbsPath(tempDir) } return s.tempDirAbsPath, nil }