commit ee28f348f299f2e0a79e0cf1c5dcc7fed90cdbb2 Author: upstream Date: Sat Oct 10 12:27:09 2026 +0200 Upstream twpayne/chezmoi state of the touched files before c6778d6771 diff --git a/internal/chezmoi/externaldiffsystem.go b/internal/chezmoi/externaldiffsystem.go new file mode 100644 index 0000000..3f5a9b3 --- /dev/null +++ b/internal/chezmoi/externaldiffsystem.go @@ -0,0 +1,462 @@ +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 +} diff --git a/internal/chezmoi/path_unix.go b/internal/chezmoi/path_unix.go new file mode 100644 index 0000000..bc9db14 --- /dev/null +++ b/internal/chezmoi/path_unix.go @@ -0,0 +1,46 @@ +//go:build unix + +package chezmoi + +import ( + "path/filepath" + "strings" +) + +var devNullAbsPath = NewAbsPath("/dev/null") + +// NewAbsPathFromExtPath returns a new AbsPath by converting extPath to use +// slashes, performing tilde expansion, and making the path absolute. +func NewAbsPathFromExtPath(extPath string, homeDirAbsPath AbsPath) (AbsPath, error) { + extPath = filepath.Clean(extPath) + switch { + case extPath == "~": + return homeDirAbsPath, nil + case strings.HasPrefix(extPath, "~/"): + return homeDirAbsPath.JoinString(extPath[2:]), nil + case filepath.IsAbs(extPath): + return NewAbsPath(extPath), nil + default: + absPath, err := filepath.Abs(extPath) + if err != nil { + return EmptyAbsPath, err + } + return NewAbsPath(absPath), nil + } +} + +// NormalizePath returns path normalized. On non-Windows systems, normalized +// paths are absolute paths. +func NormalizePath(path string) (AbsPath, error) { + absPath, err := filepath.Abs(path) + if err != nil { + return EmptyAbsPath, err + } + return NewAbsPath(absPath), nil +} + +// normalizeLinkname returns linkname normalized. On non-Windows systems, it +// returns linkname unchanged. +func normalizeLinkname(linkname string) string { + return linkname +} diff --git a/internal/chezmoi/path_windows.go b/internal/chezmoi/path_windows.go new file mode 100644 index 0000000..0b1d5f1 --- /dev/null +++ b/internal/chezmoi/path_windows.go @@ -0,0 +1,98 @@ +package chezmoi + +import ( + "path/filepath" + "strings" +) + +var devNullAbsPath = NewAbsPath("NUL:") + +// NewAbsPathFromExtPath returns a new AbsPath by converting extPath to use +// slashes, performing tilde expansion, making the path absolute, and converting +// the volume name to uppercase. +func NewAbsPathFromExtPath(extPath string, homeDirAbsPath AbsPath) (AbsPath, error) { + extPath = filepath.Clean(extPath) + switch { + case extPath == "~": + return homeDirAbsPath, nil + case len(extPath) >= 2 && extPath[0] == '~' && isSlash(extPath[1]): + return homeDirAbsPath.JoinString(filepath.ToSlash(extPath[2:])), nil + case filepath.IsAbs(extPath): + return NewAbsPath(volumeNameToUpper(extPath)).ToSlash(), nil + default: + extPath, err := filepath.Abs(extPath) + if err != nil { + return EmptyAbsPath, err + } + return NewAbsPath(volumeNameToUpper(extPath)).ToSlash(), nil + } +} + +// NormalizePath returns path normalized. On Windows, normalized paths are +// absolute paths with a uppercase volume name and forward slashes. +func NormalizePath(path string) (AbsPath, error) { + var err error + path, err = filepath.Abs(path) + if err != nil { + return EmptyAbsPath, err + } + if n := volumeNameLen(path); n > 0 { + path = strings.ToUpper(path[:n]) + path[n:] + } + return NewAbsPath(path).ToSlash(), nil +} + +// normalizeLinkname returns linkname normalized. On Windows, backslashes are +// converted to forward slashes and if linkname is an absolute path then the +// volume name is converted to uppercase. +func normalizeLinkname(linkname string) string { + if filepath.IsAbs(linkname) { + return filepath.ToSlash(volumeNameToUpper(linkname)) + } + return filepath.ToSlash(linkname) +} + +// volumeNameLen returns length of the leading volume name on Windows. It +// returns 0 elsewhere. +func volumeNameLen(path string) int { + if len(path) < 2 { + return 0 + } + // with drive letter + c := path[0] + if path[1] == ':' && ('a' <= c && c <= 'z' || 'A' <= c && c <= 'Z') { + return 2 + } + // is it UNC? https://msdn.microsoft.com/en-us/library/windows/desktop/aa365247(v=vs.85).aspx + if l := len(path); l >= 5 && isSlash(path[0]) && isSlash(path[1]) && !isSlash(path[2]) && path[2] != '.' { + // first, leading `\\` and next shouldn't be `\`. its server name. + for n := 3; n < l-1; n++ { + // second, next '\' shouldn't be repeated. + if isSlash(path[n]) { + n++ + // third, following something characters. its share name. + if !isSlash(path[n]) { + if path[n] == '.' { + break + } + for ; n < l; n++ { + if isSlash(path[n]) { + break + } + } + return n + } + break + } + } + } + return 0 +} + +// volumeNameToUpper returns path with the volume name converted to uppercase. +func volumeNameToUpper(path string) string { + if n := volumeNameLen(path); n > 0 { + return strings.ToUpper(path[:n]) + path[n:] + } + return path +} diff --git a/internal/cmd/config.go b/internal/cmd/config.go new file mode 100644 index 0000000..06d3abf --- /dev/null +++ b/internal/cmd/config.go @@ -0,0 +1,3469 @@ +package cmd + +import ( + "bufio" + "bytes" + "cmp" + "context" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "log/slog" + "maps" + "net/http" + "os" + "os/exec" + "os/user" + "path" + "path/filepath" + "reflect" + "regexp" + "runtime" + "slices" + "strconv" + "strings" + "text/template" + "time" + + "github.com/Masterminds/sprig/v3" + "github.com/bartventer/httpcache" + "github.com/betterleaks/betterleaks/detect" + "github.com/coreos/go-semver/semver" + "github.com/dustin/go-humanize/english" + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/format/diff" + "github.com/go-viper/mapstructure/v2" + "github.com/muesli/termenv" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/twpayne/go-shell" + "github.com/twpayne/go-vfs/v5" + "github.com/twpayne/go-xdg/v6" + "golang.org/x/term" + "mvdan.cc/sh/v3/expand" + "mvdan.cc/sh/v3/syntax" + + "chezmoi.io/chezmoi/v2/assets/templates" + "chezmoi.io/chezmoi/v2/internal/chezmoi" + "chezmoi.io/chezmoi/v2/internal/chezmoierrors" + "chezmoi.io/chezmoi/v2/internal/chezmoigit" + "chezmoi.io/chezmoi/v2/internal/chezmoilog" + "chezmoi.io/chezmoi/v2/internal/chezmoiset" +) + +// defaultSentinel is a string value used to indicate that the default value +// should be used. It is a string unlikely to be an actual value set by the +// user. +const defaultSentinel = "\x00" + +const ( + logComponentKey = "component" + logComponentValueEncryption = "encryption" + logComponentValuePersistentState = "persistentState" + logComponentValueSourceState = "sourceState" + logComponentValueSystem = "system" +) + +const ( + groupIDAdvanced = "advanced" + groupIDDaily = "daily" + groupIDDocumentation = "documentation" + groupIDEncryption = "encryption" + groupIDInternal = "internal" + groupIDMigration = "migration" + groupIDRemote = "remote" + groupIDTemplate = "template" +) + +var groups = []*cobra.Group{ + {ID: groupIDDocumentation, Title: "Documentation commands:"}, + {ID: groupIDDaily, Title: "Daily commands:"}, + {ID: groupIDTemplate, Title: "Template commands:"}, + {ID: groupIDAdvanced, Title: "Advanced commands:"}, + {ID: groupIDEncryption, Title: "Encryption commands:"}, + {ID: groupIDRemote, Title: "Remote commands:"}, + {ID: groupIDMigration, Title: "Migration commands:"}, + {ID: groupIDInternal, Title: "Internal commands:"}, +} + +type doPurgeOptions struct { + binary bool + cache bool + config bool + persistentState bool + sourceDir bool + workingTree bool +} + +type commandConfig struct { + Command string `json:"command" mapstructure:"command" yaml:"command"` + Script string `json:"script" mapstructure:"script" yaml:"script"` + Args []string `json:"args" mapstructure:"args" yaml:"args"` +} + +type hookConfig struct { + Pre commandConfig `json:"pre" mapstructure:"pre" yaml:"pre"` + Post commandConfig `json:"post" mapstructure:"post" yaml:"post"` +} + +type templateConfig struct { + Options []string `json:"options" mapstructure:"options" yaml:"options"` +} + +type warningsConfig struct { + ConfigFileTemplateHasChanged bool `json:"configFileTemplateHasChanged" mapstructure:"configFileTemplateHasChanged" yaml:"configFileTemplateHasChanged"` +} + +// ConfigFile contains all data settable in the config file. +type ConfigFile struct { + // Global configuration. + CacheDirAbsPath chezmoi.AbsPath `json:"cacheDir" mapstructure:"cacheDir" yaml:"cacheDir"` + Color autoBool `json:"color" mapstructure:"color" yaml:"color"` + Data map[string]any `json:"data" mapstructure:"data" yaml:"data"` + Env map[string]string `json:"env" mapstructure:"env" yaml:"env"` + Format *choiceFlag `json:"format" mapstructure:"format" yaml:"format"` + DestDirAbsPath chezmoi.AbsPath `json:"destDir" mapstructure:"destDir" yaml:"destDir"` + GitHub gitHubConfig `json:"gitHub" mapstructure:"gitHub" yaml:"gitHub"` + Hooks map[string]hookConfig `json:"hooks" mapstructure:"hooks" yaml:"hooks"` + Interactive bool `json:"interactive" mapstructure:"interactive" yaml:"interactive"` + Interpreters map[string]chezmoi.Interpreter `json:"interpreters" mapstructure:"interpreters" yaml:"interpreters"` + LessInteractive bool `json:"lessInteractive" mapstructure:"lessInteractive" yaml:"lessInteractive"` + Mode chezmoi.Mode `json:"mode" mapstructure:"mode" yaml:"mode"` + Pager string `json:"pager" mapstructure:"pager" yaml:"pager"` + PagerArgs []string `json:"pagerArgs" mapstructure:"pagerArgs" yaml:"pagerArgs"` + PersistentStateAbsPath chezmoi.AbsPath `json:"persistentState" mapstructure:"persistentState" yaml:"persistentState"` + PINEntry pinEntryConfig `json:"pinentry" mapstructure:"pinentry" yaml:"pinentry"` + Progress autoBool `json:"progress" mapstructure:"progress" yaml:"progress"` + Safe bool `json:"safe" mapstructure:"safe" yaml:"safe"` + ScriptEnv map[string]string `json:"scriptEnv" mapstructure:"scriptEnv" yaml:"scriptEnv"` + ScriptTempDir chezmoi.AbsPath `json:"scriptTempDir" mapstructure:"scriptTempDir" yaml:"scriptTempDir"` + SourceDirAbsPath chezmoi.AbsPath `json:"sourceDir" mapstructure:"sourceDir" yaml:"sourceDir"` + TempDir chezmoi.AbsPath `json:"tempDir" mapstructure:"tempDir" yaml:"tempDir"` + Template templateConfig `json:"template" mapstructure:"template" yaml:"template"` + TextConv textConv `json:"textConv" mapstructure:"textConv" yaml:"textConv"` + Umask fs.FileMode `json:"umask" mapstructure:"umask" yaml:"umask"` + UseBuiltinAge autoBool `json:"useBuiltinAge" mapstructure:"useBuiltinAge" yaml:"useBuiltinAge"` + UseBuiltinGit autoBool `json:"useBuiltinGit" mapstructure:"useBuiltinGit" yaml:"useBuiltinGit"` + Verbose bool `json:"verbose" mapstructure:"verbose" yaml:"verbose"` + Warnings warningsConfig `json:"warnings" mapstructure:"warnings" yaml:"warnings"` + WorkingTreeAbsPath chezmoi.AbsPath `json:"workingTree" mapstructure:"workingTree" yaml:"workingTree"` + + // Password manager configurations. + AWSSecretsManager awsSecretsManagerConfig `json:"awsSecretsManager" mapstructure:"awsSecretsManager" yaml:"awsSecretsManager"` + AzureKeyVault azureKeyVaultConfig `json:"azureKeyVault" mapstructure:"azureKeyVault" yaml:"azureKeyVault"` + Bitwarden bitwardenConfig `json:"bitwarden" mapstructure:"bitwarden" yaml:"bitwarden"` + BitwardenSecrets bitwardenSecretsConfig `json:"bitwardenSecrets" mapstructure:"bitwardenSecrets" yaml:"bitwardenSecrets"` + Dashlane dashlaneConfig `json:"dashlane" mapstructure:"dashlane" yaml:"dashlane"` + Doppler dopplerConfig `json:"doppler" mapstructure:"doppler" yaml:"doppler"` + Ejson ejsonConfig `json:"ejson" mapstructure:"ejson" yaml:"ejson"` + Gopass gopassConfig `json:"gopass" mapstructure:"gopass" yaml:"gopass"` + Keepassxc keepassxcConfig `json:"keepassxc" mapstructure:"keepassxc" yaml:"keepassxc"` + Keeper keeperConfig `json:"keeper" mapstructure:"keeper" yaml:"keeper"` + Lastpass lastpassConfig `json:"lastpass" mapstructure:"lastpass" yaml:"lastpass"` + Onepassword onepasswordConfig `json:"onepassword" mapstructure:"onepassword" yaml:"onepassword"` + Pass passConfig `json:"pass" mapstructure:"pass" yaml:"pass"` + Passhole passholeConfig `json:"passhole" mapstructure:"passhole" yaml:"passhole"` + ProtonPass protonPassConfig `json:"protonPass" mapstructure:"protonPass" yaml:"protonPass"` + RBW rbwConfig `json:"rbw" mapstructure:"rbw" yaml:"rbw"` + Secret secretConfig `json:"secret" mapstructure:"secret" yaml:"secret"` + Vault vaultConfig `json:"vault" mapstructure:"vault" yaml:"vault"` + + // Encryption configurations. + Encryption string `json:"encryption" mapstructure:"encryption" yaml:"encryption"` + Age chezmoi.AgeEncryption `json:"age" mapstructure:"age" yaml:"age"` + GPG chezmoi.GPGEncryption `json:"gpg" mapstructure:"gpg" yaml:"gpg"` + + // Command configurations. + Add addCmdConfig `json:"add" mapstructure:"add" yaml:"add"` + CD cdCmdConfig `json:"cd" mapstructure:"cd" yaml:"cd"` + Completion completionCmdConfig `json:"completion" mapstructure:"completion" yaml:"completion"` + Docker dockerCmdConfig `json:"docker" mapstructure:"docker" yaml:"docker"` + Diff diffCmdConfig `json:"diff" mapstructure:"diff" yaml:"diff"` + Edit editCmdConfig `json:"edit" mapstructure:"edit" yaml:"edit"` + Git gitCmdConfig `json:"git" mapstructure:"git" yaml:"git"` + Merge mergeCmdConfig `json:"merge" mapstructure:"merge" yaml:"merge"` + Status statusCmdConfig `json:"status" mapstructure:"status" yaml:"status"` + Update updateCmdConfig `json:"update" mapstructure:"update" yaml:"update"` + Verify verifyCmdConfig `json:"verify" mapstructure:"verify" yaml:"verify"` +} + +// A Config represents a configuration. +type Config struct { + ConfigFile + + // Global configuration. + ageRecipient string + ageRecipientFile string + configFormat *choiceFlag + debug bool + dryRun bool + force bool + homeDir string + keepGoing bool + noPager bool + noTTY bool + outputAbsPath chezmoi.AbsPath + refreshExternals chezmoi.RefreshExternals + skipSecrets bool + sourcePath bool + errorOnConflict bool + templateFuncs template.FuncMap + useBuiltinDiff bool + + // Password manager data. + gitHub gitHubData + keyring keyringData + + // Command configurations, not settable in the config file. + age ageCmdConfig + ageKeygen ageKeygenCmdConfig + apply applyCmdConfig + archive archiveCmdConfig + chattr chattrCmdConfig + data dataCmdConfig + destroy destroyCmdConfig + doctor doctorCmdConfig + dump dumpCmdConfig + dumpConfig dumpConfigCmdConfig + executeTemplate executeTemplateCmdConfig + generate generateCmdConfig + ignored ignoredCmdConfig + _import importCmdConfig + init initCmdConfig + managed managedCmdConfig + mergeAll mergeAllCmdConfig + ssh sshCmdConfig + purge purgeCmdConfig + reAdd reAddCmdConfig + secret secretCmdConfig + state stateCmdConfig + unmanaged unmanagedCmdConfig + upgrade upgradeCmdConfig + + // Common configuration. + interactiveTemplateFuncs interactiveTemplateFuncsConfig + overrideData string + overrideDataFileAbsPath chezmoi.AbsPath + + // Version information. + version semver.Version + versionInfo VersionInfo + versionStr string + + // Configuration. + fileSystem vfs.FS + bds *xdg.BaseDirectorySpecification + defaultConfigFileAbsPath chezmoi.AbsPath + defaultConfigFileAbsPathErr error + customConfigFileAbsPath chezmoi.AbsPath + baseSystem chezmoi.System + sourceSystem chezmoi.System + destSystem chezmoi.System + persistentState chezmoi.PersistentState + httpClient *http.Client + logger *slog.Logger + + // Computed configuration. + commandDirAbsPath chezmoi.AbsPath + homeDirAbsPath chezmoi.AbsPath + encryption chezmoi.Encryption + sourceDirAbsPath chezmoi.AbsPath + sourceDirAbsPathErr error + sourceState *chezmoi.SourceState + sourceStateErr error + templateData *templateData + betterleaksDetector *detect.Detector + betterleaksDetectorErr error + + stdin io.Reader + stdout io.Writer + stderr io.Writer + bufioReader *bufio.Reader + diffPagerCmdStdin io.WriteCloser + diffPagerCmd *exec.Cmd + + tempDirs map[string]chezmoi.AbsPath + + ioregData ioregData + + restoreWindowsConsole func() error +} + +type templateDataFlags struct { + dryRun bool + force bool + keepGoing bool + noPager bool + noTTY bool +} + +type templateData struct { + arch string + args []string + cacheDir string + command string + commandDir string + config map[string]any + configFile string + destDir string + executable string + flags templateDataFlags + fqdnHostname string + gid string + group string + homeDir string + hostname string + kernel map[string]any + os string + osRelease map[string]any + pathListSeparator string + pathSeparator string + rawHomeDir string + sourceDir string + uid string + username string + version map[string]any + windowsVersion map[string]any + workingTree string +} + +// A configOption sets and option on a Config. +type configOption func(*Config) error + +type configState struct { + ConfigTemplateContentsSHA256 chezmoi.HexBytes `json:"configTemplateContentsSHA256" yaml:"configTemplateContentsSHA256"` //nolint:tagliatelle +} + +var ( + chezmoiRelPath = chezmoi.NewRelPath("chezmoi") + persistentStateFileRelPath = chezmoi.NewRelPath("chezmoistate.boltdb") + + configStateKey = []byte("configState") + + defaultAgeEncryptionConfig = chezmoi.AgeEncryption{ + Command: "age", + Suffix: ".age", + } + defaultGPGEncryptionConfig = chezmoi.GPGEncryption{ + Command: "gpg", + Suffix: ".asc", + } + + whitespaceRx = regexp.MustCompile(`\s+`) + + commonFlagCompletionFuncs = map[string]func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective){ + "exclude": chezmoi.EntryTypeSetFlagCompletionFunc, + "include": chezmoi.EntryTypeSetFlagCompletionFunc, + } +) + +// newConfig creates a new Config with the given options. +func newConfig(options ...configOption) (*Config, error) { + userHomeDir, err := chezmoi.UserHomeDir() + if err != nil { + return nil, err + } + homeDirAbsPath, err := chezmoi.NormalizePath(userHomeDir) + if err != nil { + return nil, err + } + + bds, err := xdg.NewBaseDirectorySpecification() + if err != nil { + return nil, err + } + + logger := slog.Default() + + c := &Config{ + ConfigFile: newConfigFile(bds), + + // Global configuration. + configFormat: newChoiceFlag("", readDataFormatValues), + homeDir: userHomeDir, + templateFuncs: sprig.TxtFuncMap(), + + // Command configurations. + apply: applyCmdConfig{ + filter: chezmoi.NewEntryTypeFilter(chezmoi.EntryTypesAll, chezmoi.EntryTypesNone), + recursive: true, + }, + archive: archiveCmdConfig{ + filter: chezmoi.NewEntryTypeFilter(chezmoi.EntryTypesAll, chezmoi.EntryTypesNone), + format: newChoiceFlag("tar.gz", archiveFormatValues), + recursive: true, + }, + data: dataCmdConfig{ + format: newChoiceFlag("", writeDataFormatValues), + }, + dump: dumpCmdConfig{ + filter: chezmoi.NewEntryTypeFilter(chezmoi.EntryTypesAll, chezmoi.EntryTypesNone), + format: newChoiceFlag("", writeDataFormatValues), + recursive: true, + }, + dumpConfig: dumpConfigCmdConfig{ + format: newChoiceFlag("", writeDataFormatValues), + }, + executeTemplate: executeTemplateCmdConfig{ + stdinIsATTY: true, + }, + generate: generateCmdConfig{ + installInitShellSh: generateInstallInitShellShCmdConfig{ + interactive: true, + shell: true, + }, + }, + _import: importCmdConfig{ + destination: homeDirAbsPath, + filter: chezmoi.NewEntryTypeFilter(chezmoi.EntryTypesAll, chezmoi.EntryTypesNone), + }, + init: initCmdConfig{ + data: true, + filter: chezmoi.NewEntryTypeFilter(chezmoi.EntryTypesAll, chezmoi.EntryTypesNone), + guessRepoURL: true, + recurseSubmodules: true, + }, + managed: managedCmdConfig{ + filter: chezmoi.NewEntryTypeFilter(chezmoi.EntryTypesAll, chezmoi.EntryTypesNone), + format: newChoiceFlag(formatJSON, writeDataFormatValues), + pathStyle: newChoiceFlag(pathStyleRelative, sourceOrTargetPathStyleValues), + }, + mergeAll: mergeAllCmdConfig{ + recursive: true, + }, + reAdd: reAddCmdConfig{ + filter: chezmoi.NewEntryTypeFilter(chezmoi.EntryTypesAll, chezmoi.EntryTypesNone), + recursive: true, + }, + ssh: sshCmdConfig{ + shell: true, + }, + state: stateCmdConfig{ + data: stateDataCmdConfig{ + format: newChoiceFlag(formatJSON, writeDataFormatValues), + }, + dump: stateDumpCmdConfig{ + format: newChoiceFlag(formatJSON, writeDataFormatValues), + }, + getBucket: stateGetBucketCmdConfig{ + format: newChoiceFlag(formatJSON, writeDataFormatValues), + }, + }, + unmanaged: unmanagedCmdConfig{ + filter: chezmoi.NewEntryTypeFilter(chezmoi.EntryTypesAll, chezmoi.EntryTypesNone), + pathStyle: newChoiceFlag(pathStyleRelative, targetPathStyleValues), + }, + + // Configuration. + fileSystem: vfs.OSFS, + bds: bds, + logger: logger, + + // Computed configuration. + homeDirAbsPath: homeDirAbsPath, + + tempDirs: make(map[string]chezmoi.AbsPath), + + stdin: os.Stdin, + stdout: os.Stdout, + stderr: os.Stderr, + } + + // Override sprig template functions. Delete them from the template function + // map first to avoid a duplicate function panic. + for _, templateFunc := range []string{ + "fromJson", + "quote", + "splitList", + "squote", + "toPrettyJson", + "toString", + "toStrings", + } { + if _, ok := c.templateFuncs[templateFunc]; !ok { + panic(templateFunc + ": deleting non-existent template function") + } + delete(c.templateFuncs, templateFunc) + } + + // The completion template function is added in persistentPreRunRootE as + // it needs a *cobra.Command, which we don't yet have. + for key, value := range map[string]any{ + "abortEmpty": chezmoi.AbortEmptyTemplateFunc, + "awsSecretsManager": c.awsSecretsManagerTemplateFunc, + "awsSecretsManagerRaw": c.awsSecretsManagerRawTemplateFunc, + "azureKeyVault": c.azureKeyVaultTemplateFunc, + "bitwarden": c.bitwardenTemplateFunc, + "bitwardenAttachment": c.bitwardenAttachmentTemplateFunc, + "bitwardenAttachmentByRef": c.bitwardenAttachmentByRefTemplateFunc, + "bitwardenFields": c.bitwardenFieldsTemplateFunc, + "bitwardenSecrets": c.bitwardenSecretsTemplateFunc, + "comment": c.commentTemplateFunc, + "dashlaneNote": c.dashlaneNoteTemplateFunc, + "dashlanePassword": c.dashlanePasswordTemplateFunc, + "debugf": c.debugfTemplateFunc, + "decrypt": c.decryptTemplateFunc, + "deleteValueAtPath": c.deleteValueAtPathTemplateFunc, + "doppler": c.dopplerTemplateFunc, + "dopplerProjectJson": c.dopplerProjectJSONTemplateFunc, + "ejsonDecrypt": c.ejsonDecryptTemplateFunc, + "ejsonDecryptWithKey": c.ejsonDecryptWithKeyTemplateFunc, + "encrypt": c.encryptTemplateFunc, + "ensureLinePrefix": c.ensureLinePrefixTemplateFunc, + "eqFold": c.eqFoldTemplateFunc, + "exec": c.execTemplateFunc, + "findExecutable": c.findExecutableTemplateFunc, + "findOneExecutable": c.findOneExecutableTemplateFunc, + "fromIni": c.fromIniTemplateFunc, + "fromJson": c.fromJsonTemplateFunc, + "fromJsonc": c.fromJsoncTemplateFunc, + "fromToml": c.fromTomlTemplateFunc, + "fromYaml": c.fromYamlTemplateFunc, + "getRedirectedURL": c.getRedirectedURLTemplateFunc, + "gitHubKeys": c.gitHubKeysTemplateFunc, + "gitHubLatestRelease": c.gitHubLatestReleaseTemplateFunc, + "gitHubLatestReleaseAssetURL": c.gitHubLatestReleaseAssetURLTemplateFunc, + "gitHubLatestTag": c.gitHubLatestTagTemplateFunc, + "gitHubRelease": c.gitHubReleaseTemplateFunc, + "gitHubReleaseAssetURL": c.gitHubReleaseAssetURLTemplateFunc, + "gitHubReleases": c.gitHubReleasesTemplateFunc, + "gitHubTags": c.gitHubTagsTemplateFunc, + "glob": c.globTemplateFunc, + "globCaseInsensitive": c.globCaseInsensitiveTemplateFunc, + "gopass": c.gopassTemplateFunc, + "gopassCat": c.gopassCatTemplateFunc, + "gopassRaw": c.gopassRawTemplateFunc, + "hexDecode": c.hexDecodeTemplateFunc, + "hexEncode": c.hexEncodeTemplateFunc, + "include": c.includeTemplateFunc, + "includeTemplate": c.includeTemplateTemplateFunc, + "ioreg": c.ioregTemplateFunc, + "isExecutable": c.isExecutableTemplateFunc, + "joinPath": c.joinPathTemplateFunc, + "jq": c.jqTemplateFunc, + "keepassxc": c.keepassxcTemplateFunc, + "keepassxcAttachment": c.keepassxcAttachmentTemplateFunc, + "keepassxcAttribute": c.keepassxcAttributeTemplateFunc, + "keeper": c.keeperTemplateFunc, + "keeperDataFields": c.keeperDataFieldsTemplateFunc, + "keeperFindPassword": c.keeperFindPasswordTemplateFunc, + "keyring": c.keyringTemplateFunc, + "lastpass": c.lastpassTemplateFunc, + "lastpassRaw": c.lastpassRawTemplateFunc, + "lookPath": c.lookPathTemplateFunc, + "lstat": c.lstatTemplateFunc, + "mozillaInstallHash": c.mozillaInstallHashTemplateFunc, + "onepassword": c.onepasswordTemplateFunc, + "onepasswordDetailsFields": c.onepasswordDetailsFieldsTemplateFunc, + "onepasswordDocument": c.onepasswordDocumentTemplateFunc, + "onepasswordItemFields": c.onepasswordItemFieldsTemplateFunc, + "onepasswordRead": c.onepasswordReadTemplateFunc, + "output": c.outputTemplateFunc, + "outputList": c.outputListTemplateFunc, + "pass": c.passTemplateFunc, + "passFields": c.passFieldsTemplateFunc, + "passRaw": c.passRawTemplateFunc, + "passhole": c.passholeTemplateFunc, + "protonPass": c.protonPassTemplateFunc, + "protonPassAttachment": c.protonPassAttachmentTemplateFunc, + "protonPassJSON": c.protonPassJSONTemplateFunc, + "pruneEmptyDicts": c.pruneEmptyDictsTemplateFunc, + "quote": c.quoteTemplateFunc, + "quoteList": c.quoteListTemplateFunc, + "rbw": c.rbwTemplateFunc, + "rbwFields": c.rbwFieldsTemplateFunc, + "replaceAllRegex": c.replaceAllRegexTemplateFunc, + "secret": c.secretTemplateFunc, + "secretJSON": c.secretJSONTemplateFunc, + "setValueAtPath": c.setValueAtPathTemplateFunc, + "shellQuote": c.shellQuoteTemplateFunc, + "shellQuoteList": c.shellQuoteListTemplateFunc, + "splitList": c.splitListTemplateFunc, + "squote": c.squoteTemplateFunc, + "stat": c.statTemplateFunc, + "stdinIsATTY": c.stdinIsATTYTemplateFunc, + "toIni": c.toIniTemplateFunc, + "toPrettyJson": c.toPrettyJsonTemplateFunc, + "toString": c.toStringTemplateFunc, + "toStrings": c.toStringsTemplateFunc, + "toToml": c.toTomlTemplateFunc, + "toYaml": c.toYamlTemplateFunc, + "vault": c.vaultTemplateFunc, + "warnf": c.warnfTemplateFunc, + } { + c.addTemplateFunc(key, value) + } + + for _, option := range options { + if err := option(c); err != nil { + return nil, err + } + } + + wd, err := os.Getwd() + if err != nil { + return nil, err + } + c.commandDirAbsPath, err = chezmoi.NormalizePath(wd) + if err != nil { + return nil, err + } + c.homeDirAbsPath, err = chezmoi.NormalizePath(c.homeDir) + if err != nil { + return nil, err + } + c.defaultConfigFileAbsPath, c.defaultConfigFileAbsPathErr = c.defaultConfigFile(c.fileSystem, c.bds) + c.SourceDirAbsPath, err = c.defaultSourceDir(c.fileSystem, c.bds) + if err != nil { + return nil, err + } + c.DestDirAbsPath = c.homeDirAbsPath + c._import.destination = c.homeDirAbsPath + + return c, nil +} + +// Close closes resources associated with c. +func (c *Config) Close() error { + errs := make([]error, 0, len(c.tempDirs)) + for _, tempDirAbsPath := range c.tempDirs { + err := os.RemoveAll(tempDirAbsPath.String()) + chezmoilog.InfoOrError(c.logger, "RemoveAll", err, + chezmoilog.Stringer("tempDir", tempDirAbsPath), + ) + errs = append(errs, err) + } + return chezmoierrors.Combine(errs...) +} + +// addTemplateFunc adds the template function with the given key and value +// to c. It panics if there is already an existing template function with the +// same key. +func (c *Config) addTemplateFunc(key string, value any) { + if _, ok := c.templateFuncs[key]; ok { + panic(key + ": already defined") + } + c.templateFuncs[key] = value +} + +type applyArgsOptions struct { + cmd *cobra.Command + filter *chezmoi.EntryTypeFilter + init bool + parentDirs bool + recursive bool + umask fs.FileMode + preApplyFunc chezmoi.PreApplyFunc +} + +// applyArgs is the core of all commands that make changes to a target system. +// It checks config file freshness, reads the source state, and then applies the +// source state for each target entry in args. If args is empty then the source +// state is applied to all target entries. +func (c *Config) applyArgs( + ctx context.Context, + targetSystem chezmoi.System, + targetDirAbsPath chezmoi.AbsPath, + args []string, + options applyArgsOptions, +) error { + if options.init { + if err := c.createAndReloadConfigFile(options.cmd); err != nil { + return err + } + } + + var currentConfigTemplateContentsSHA256 []byte + configTemplate, err := c.findConfigTemplate() + if err != nil { + return err + } + if configTemplate != nil { + currentConfigTemplateContentsSHA256 = sha256Sum(configTemplate.contents) + } + var previousConfigTemplateContentsSHA256 []byte + if configStateData, err := c.persistentState.Get(chezmoi.ConfigStateBucket, configStateKey); err != nil { + return err + } else if configStateData != nil { + var configState configState + if err := json.Unmarshal(configStateData, &configState); err != nil { + return err + } + previousConfigTemplateContentsSHA256 = []byte(configState.ConfigTemplateContentsSHA256) + } + configTemplatesEmpty := currentConfigTemplateContentsSHA256 == nil && previousConfigTemplateContentsSHA256 == nil + configTemplateContentsUnchanged := configTemplatesEmpty || + bytes.Equal(currentConfigTemplateContentsSHA256, previousConfigTemplateContentsSHA256) + if !configTemplateContentsUnchanged { + if c.force { + if configTemplate == nil { + if err := c.persistentState.Delete(chezmoi.ConfigStateBucket, configStateKey); err != nil { + return err + } + } else { + configStateValue, err := chezmoi.FormatJSON.Marshal(configState{ + ConfigTemplateContentsSHA256: chezmoi.HexBytes(currentConfigTemplateContentsSHA256), + }) + if err != nil { + return err + } + if err := c.persistentState.Set(chezmoi.ConfigStateBucket, configStateKey, configStateValue); err != nil { + return err + } + } + } else if c.Warnings.ConfigFileTemplateHasChanged { + c.errorf("warning: config file template has changed, run chezmoi init to regenerate config file\n") + } + } + + sourceState, err := c.getSourceState(ctx, options.cmd) + if err != nil { + return err + } + + var targetRelPaths []chezmoi.RelPath + switch { + case len(args) == 0: + targetRelPaths = sourceState.TargetRelPaths() + case c.sourcePath: + targetRelPaths, err = c.targetRelPathsBySourcePath(sourceState, args) + if err != nil { + return err + } + default: + targetRelPaths, err = c.targetRelPaths(sourceState, args, targetRelPathsOptions{ + recursive: options.recursive, + }) + if err != nil { + return err + } + } + + if options.parentDirs { + targetRelPaths = prependParentRelPaths(targetRelPaths) + } + + applyOptions := chezmoi.ApplyOptions{ + Filter: options.filter, + PreApplyFunc: options.preApplyFunc, + Umask: options.umask, + } + + keptGoingAfterErr := false + for _, targetRelPath := range targetRelPaths { + switch err := sourceState.Apply(targetSystem, c.destSystem, c.persistentState, targetDirAbsPath, targetRelPath, applyOptions); { + case errors.Is(err, fs.SkipDir): + continue + case err != nil: + err = fmt.Errorf("%s: %w", targetRelPath, err) + if !c.keepGoing { + return err + } + c.errorf("%v\n", err) + keptGoingAfterErr = true + } + } + + switch err := sourceState.PostApply(targetSystem, c.persistentState, targetDirAbsPath, targetRelPaths); { + case err != nil && c.keepGoing: + c.errorf("%v\n", err) + keptGoingAfterErr = true + case err != nil: + return err + } + + if keptGoingAfterErr { + return chezmoi.ExitCodeError(1) + } + + return nil +} + +// builtinDiffFile outputs the diff between fromData and fromMode and toData and +// toMode at path. +func (c *Config) builtinDiffFile( + relPath chezmoi.RelPath, + fromData []byte, fromMode fs.FileMode, + toData []byte, toMode fs.FileMode, +) error { + builder := strings.Builder{} + unifiedEncoder := diff.NewUnifiedEncoder(&builder, diff.DefaultContextLines) + color := c.Color.Value(c.colorAutoFunc) + if color { + unifiedEncoder.SetColor(diff.NewColorConfig()) + } + if fromMode.IsRegular() { + var err error + fromData, _, err = c.TextConv.convert(relPath.String(), fromData) + if err != nil { + return err + } + } + if toMode.IsRegular() { + var err error + toData, _, err = c.TextConv.convert(relPath.String(), toData) + if err != nil { + return err + } + } + diffPatch, err := chezmoi.DiffPatch(relPath, fromData, fromMode, toData, toMode) + if err != nil { + return err + } + if err := unifiedEncoder.Encode(diffPatch); err != nil { + return err + } + return c.pageDiffOutput(builder.String()) +} + +// checkVersion checks that chezmoi is at least the required version for the +// source state. +func (c *Config) checkVersion() error { + versionAbsPath := c.SourceDirAbsPath.JoinString(chezmoi.VersionName) + switch data, err := c.baseSystem.ReadFile(versionAbsPath); { + case errors.Is(err, fs.ErrNotExist): + case err != nil: + return err + default: + minVersion, err := semver.NewVersion(strings.TrimSpace(string(data))) + if err != nil { + return fmt.Errorf("%s: %q: %w", versionAbsPath, data, err) + } + var zeroVersion semver.Version + if c.version != zeroVersion && c.version.LessThan(*minVersion) { + return &chezmoi.TooOldError{ + Need: *minVersion, + Have: c.version, + } + } + } + return nil +} + +// cmdOutput returns the of running the command name with args in dirAbsPath. +func (c *Config) cmdOutput(dirAbsPath chezmoi.AbsPath, name string, args []string) ([]byte, error) { + cmd := exec.Command(name, args...) + cmd.Stderr = os.Stderr + if !dirAbsPath.IsEmpty() { + dirRawAbsPath, err := c.baseSystem.RawPath(dirAbsPath) + if err != nil { + return nil, err + } + cmd.Dir = dirRawAbsPath.String() + } + return chezmoilog.LogCmdOutput(slog.Default(), cmd) +} + +// colorAutoFunc detects whether color should be used. +func (c *Config) colorAutoFunc() bool { + if _, ok := os.LookupEnv("NO_COLOR"); ok { + return false + } + if stdout, ok := c.stdout.(*os.File); ok { + return term.IsTerminal(int(stdout.Fd())) + } + return false +} + +// createAndReloadConfigFile creates a config file if it there is a config file +// template and reloads it. +func (c *Config) createAndReloadConfigFile(cmd *cobra.Command) error { + // Refresh the source directory, as there might be a .chezmoiroot file and + // the template data is set before .chezmoiroot is read. + sourceDirAbsPath, err := c.getSourceDirAbsPath(&getSourceDirAbsPathOptions{ + refresh: true, + }) + if err != nil { + return err + } + c.templateData.sourceDir = sourceDirAbsPath.String() + if err := os.Setenv("CHEZMOI_SOURCE_DIR", sourceDirAbsPath.String()); err != nil { + return err + } + + // Find config template, execute it, and create config file. + configTemplate, err := c.findConfigTemplate() + if err != nil { + return err + } + + if configTemplate == nil { + return c.persistentState.Delete(chezmoi.ConfigStateBucket, configStateKey) + } + + configFileContents, err := c.createConfigFileContents(configTemplate.targetRelPath, configTemplate.contents, cmd) + if err != nil { + return err + } + + // Validate the config file. + var configFile ConfigFile + if err := c.decodeConfigContents(configTemplate.format, configFileContents, &configFile); err != nil { + return fmt.Errorf("%s: %w", configTemplate.sourceAbsPath, err) + } + + // Write the config. + configPath := c.init.configPath + if c.init.configPath.IsEmpty() { + if c.customConfigFileAbsPath.IsEmpty() { + configPath = chezmoi.NewAbsPath(c.bds.ConfigHome).Join(chezmoiRelPath, configTemplate.targetRelPath) + } else { + configPath = c.customConfigFileAbsPath + } + } + if err := chezmoi.MkdirAll(c.destSystem, configPath.Dir(), fs.ModePerm); err != nil { + return err + } + if err := c.destSystem.WriteFile(configPath, configFileContents, 0o600); err != nil { + return err + } + + configStateValue, err := chezmoi.FormatJSON.Marshal(configState{ + ConfigTemplateContentsSHA256: chezmoi.HexBytes(sha256Sum(configTemplate.contents)), + }) + if err != nil { + return err + } + if err := c.persistentState.Set(chezmoi.ConfigStateBucket, configStateKey, configStateValue); err != nil { + return err + } + + // Reload the config. + if err := c.decodeConfigContents(configTemplate.format, configFileContents, &c.ConfigFile); err != nil { + return fmt.Errorf("%s: %w", configTemplate.sourceAbsPath, err) + } + + if err := c.setEncryption(); err != nil { + return err + } + + return c.setEnvironmentVariables() +} + +// createConfigFileContents creates a config file using a template and returns +// its contents. +func (c *Config) createConfigFileContents(filename chezmoi.RelPath, data []byte, cmd *cobra.Command) ([]byte, error) { + // Clone funcMap and restore it after creating the config. + // This ensures that the init template functions + // are removed before "normal" template parsing. + funcMap := make(template.FuncMap) + chezmoi.RecursiveMerge(funcMap, c.templateFuncs) + defer func() { + c.templateFuncs = funcMap + }() + + initTemplateFuncs := map[string]any{ + "exit": c.exitInitTemplateFunc, + "promptBool": c.promptBoolInteractiveTemplateFunc, + "promptBoolOnce": c.promptBoolOnceInteractiveTemplateFunc, + "promptChoice": c.promptChoiceInteractiveTemplateFunc, + "promptChoiceOnce": c.promptChoiceOnceInteractiveTemplateFunc, + "promptInt": c.promptIntInteractiveTemplateFunc, + "promptIntOnce": c.promptIntOnceInteractiveTemplateFunc, + "promptMultichoice": c.promptMultichoiceInteractiveTemplateFunc, + "promptMultichoiceOnce": c.promptMultichoiceOnceInteractiveTemplateFunc, + "promptString": c.promptStringInteractiveTemplateFunc, + "promptStringOnce": c.promptStringOnceInteractiveTemplateFunc, + "writeToStdout": c.writeToStdout, + } + chezmoi.RecursiveMerge(c.templateFuncs, initTemplateFuncs) + + tmpl, err := chezmoi.ParseTemplate(filename.String(), data, chezmoi.TemplateOptions{ + Funcs: c.templateFuncs, + Options: slices.Clone(c.Template.Options), + }) + if err != nil { + return nil, err + } + + templateData := c.getTemplateDataMap(cmd) + if c.init.data { + chezmoi.RecursiveMerge(templateData, c.Data) + } + return tmpl.Execute(templateData) +} + +// decodeConfigContents decodes data in format into configFile. +func (c *Config) decodeConfigContents(format chezmoi.Format, contents []byte, configFile *ConfigFile) error { + var configMap map[string]any + if err := format.Unmarshal(contents, &configMap); err != nil { + return err + } + return c.decodeConfigMap(configMap, configFile) +} + +// decodeConfigFile decodes the config file at configFileAbsPath into +// configFile. +func (c *Config) decodeConfigFile(configFileAbsPath chezmoi.AbsPath, configFile *ConfigFile) error { + var format chezmoi.Format + switch formatStr := c.configFormat.String(); formatStr { + case "": + var err error + format, err = chezmoi.FormatFromAbsPath(configFileAbsPath) + if err != nil { + return err + } + case formatJSON: + format = chezmoi.FormatJSON + case formatTOML: + format = chezmoi.FormatTOML + case formatYAML: + format = chezmoi.FormatYAML + default: + return fmt.Errorf("%s: invalid format", formatStr) + } + + configFileContents, err := c.fileSystem.ReadFile(configFileAbsPath.String()) + if err != nil { + return fmt.Errorf("%s: %w", configFileAbsPath, err) + } + + if err := c.decodeConfigContents(format, configFileContents, configFile); err != nil { + return fmt.Errorf("%s: %w", configFileAbsPath, err) + } + + if configFile.Git.CommitMessageTemplate != "" && configFile.Git.CommitMessageTemplateFile != "" { + return fmt.Errorf( + "%s: cannot specify both git.commitMessageTemplate and git.commitMessageTemplateFile", + configFileAbsPath, + ) + } + + return nil +} + +// decodeConfigMap decodes configMap into configFile. +func (c *Config) decodeConfigMap(configMap map[string]any, configFile *ConfigFile) error { + decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ + DecodeHook: mapstructure.ComposeDecodeHookFunc( + mapstructure.StringToTimeDurationHookFunc(), + mapstructure.StringToSliceHookFunc(","), + chezmoi.StringSliceToEntryTypeSetHookFunc(), + chezmoi.StringToAbsPathHookFunc(), + StringOrBoolToAutoBoolHookFunc(), + StringToChoiceFlagHookFunc(), + ), + Result: configFile, + }) + if err != nil { + return err + } + return decoder.Decode(configMap) +} + +// defaultConfigFile returns the default config file according to the XDG Base +// Directory Specification. +func (c *Config) defaultConfigFile(fileSystem vfs.FS, bds *xdg.BaseDirectorySpecification) (chezmoi.AbsPath, error) { + // Search XDG Base Directory Specification config directories first. +CONFIG_DIR: + for _, configDir := range bds.ConfigDirs { + configDirAbsPath, err := chezmoi.NewAbsPathFromExtPath(configDir, c.homeDirAbsPath) + if err != nil { + return chezmoi.EmptyAbsPath, err + } + + dirEntries, err := fileSystem.ReadDir(configDirAbsPath.JoinString("chezmoi").String()) + switch { + case errors.Is(err, fs.ErrNotExist): + continue CONFIG_DIR + case err != nil: + return chezmoi.EmptyAbsPath, err + } + + dirEntryNames := chezmoiset.NewWithCapacity[string](len(dirEntries)) + for _, dirEntry := range dirEntries { + dirEntryNames.Add(dirEntry.Name()) + } + + var names []string + for _, extension := range chezmoi.FormatExtensions { + name := "chezmoi." + extension + if dirEntryNames.Contains(name) { + names = append(names, name) + } + } + + switch len(names) { + case 0: + // Do nothing. + case 1: + return configDirAbsPath.JoinString("chezmoi", names[0]), nil + default: + configFileAbsPathStrs := make([]string, 0, len(names)) + for _, name := range names { + configFileAbsPathStr := configDirAbsPath.JoinString("chezmoi", name) + configFileAbsPathStrs = append(configFileAbsPathStrs, configFileAbsPathStr.String()) + } + return chezmoi.EmptyAbsPath, fmt.Errorf("multiple config files: %s", english.OxfordWordSeries(configFileAbsPathStrs, "and")) + } + } + + // Fallback to XDG Base Directory Specification default. + configHomeAbsPath, err := chezmoi.NewAbsPathFromExtPath(bds.ConfigHome, c.homeDirAbsPath) + if err != nil { + return chezmoi.EmptyAbsPath, err + } + return configHomeAbsPath.JoinString("chezmoi", "chezmoi.toml"), nil +} + +// defaultPreApplyFunc is the default pre-apply function. If the target entry +// has changed since chezmoi last wrote it then it prompts the user for the +// action to take. +func (c *Config) defaultPreApplyFunc( + targetRelPath chezmoi.RelPath, + targetEntryState, lastWrittenEntryState, actualEntryState *chezmoi.EntryState, +) error { + c.logger.Info("defaultPreApplyFunc", + chezmoilog.Stringer("targetRelPath", targetRelPath), + slog.Any("targetEntryState", targetEntryState), + slog.Any("lastWrittenEntryState", lastWrittenEntryState), + slog.Any("actualEntryState", actualEntryState), + ) + + switch { + case c.force: + return nil + case targetEntryState.Equivalent(actualEntryState): + return nil + } + + // Prepare decision for which kind of prompt we need (if any) + type promptMode int + const ( + promptNone promptMode = iota + promptYesNoAll // yes/no/all/quit (just ask, don't indicate if there is a conflict) + promptConflict // overwrite/all-overwrite/skip/quit (conflict-specific prompt) + ) + mode := promptNone + + targetDirty := lastWrittenEntryState != nil && !lastWrittenEntryState.Equivalent(actualEntryState) + targetPreExisting := lastWrittenEntryState == nil && actualEntryState.Type != chezmoi.EntryStateTypeRemove + + // Select prompt mode based on command line flag + switch { + case c.Interactive: // Prompt no matter what + mode = promptYesNoAll + case c.LessInteractive: // Prompt if target is dirty or pre-existing (i.e., only overwrite what chezmoi has written) + if targetDirty || targetPreExisting { + mode = promptConflict + } + default: // Prompt in *some* cases of a dirty target: + switch { + case targetEntryState.Overwrite(): + mode = promptNone + case targetEntryState.Type == chezmoi.EntryStateTypeScript: + mode = promptNone + case lastWrittenEntryState == nil: + mode = promptNone + case lastWrittenEntryState.Equivalent(actualEntryState): + mode = promptNone + case targetDirty: + mode = promptConflict + } + } + + switch { + case mode == promptNone: + return nil + case mode == promptConflict && c.errorOnConflict: + return chezmoi.ExitCodeError(1) + } + + // Now prompt based on choice made above + actualContents := actualEntryState.Contents() + targetContents := targetEntryState.Contents() + var choices []string + if actualContents != nil || targetContents != nil { + choices = append(choices, "diff") + } + var promptText string + if mode == promptYesNoAll { + choices = append(choices, choicesYesNoAllQuit...) + promptText = fmt.Sprintf("Apply %s", targetRelPath) + } else { + choices = append(choices, choicesOverwrite...) + if targetDirty { + promptText = fmt.Sprintf("%s has changed since chezmoi last wrote it", targetRelPath) + } else { + promptText = fmt.Sprintf("%s already exists", targetRelPath) + } + } + + for { + switch choice, err := c.promptChoice(promptText, choices); { + case err != nil: + return err + case choice == "diff": + if err := c.diffFile( + targetRelPath, + c.DestDirAbsPath.Join(targetRelPath), actualContents, actualEntryState.Mode, + chezmoi.EmptyAbsPath, targetContents, targetEntryState.Mode, + ); err != nil { + return err + } + case choice == "yes": + return nil + case choice == "no": + return fs.SkipDir + case choice == "all": + // Delicate difference to all-overwrite (mainly for backwards compatibility): Disabling --interactive means + // we still prompt for dirty files, whereas all-overwrite adds --force to really prompt no more. + c.Interactive = false + return nil + case choice == "overwrite": + return nil + case choice == "all-overwrite": + c.force = true + return nil + case choice == "skip": + return fs.SkipDir + case choice == "quit": + return chezmoi.ExitCodeError(0) + default: + panic(choice + ": unexpected choice") + } + } +} + +// defaultSourceDir returns the default source directory according to the XDG +// Base Directory Specification. +func (c *Config) defaultSourceDir(fileSystem vfs.Stater, bds *xdg.BaseDirectorySpecification) (chezmoi.AbsPath, error) { + // Check for XDG Base Directory Specification data directories first. + for _, dataDir := range bds.DataDirs { + dataDirAbsPath, err := chezmoi.NewAbsPathFromExtPath(dataDir, c.homeDirAbsPath) + if err != nil { + return chezmoi.EmptyAbsPath, err + } + sourceDirAbsPath := dataDirAbsPath.Join(chezmoiRelPath) + if _, err := fileSystem.Stat(sourceDirAbsPath.String()); err == nil { + return sourceDirAbsPath, nil + } + } + // Fallback to XDG Base Directory Specification default. + dataHomeAbsPath, err := chezmoi.NewAbsPathFromExtPath(bds.DataHome, c.homeDirAbsPath) + if err != nil { + return chezmoi.EmptyAbsPath, err + } + return dataHomeAbsPath.Join(chezmoiRelPath), nil +} + +type onNotExist int + +const ( + onNotExistError onNotExist = iota + onNotExistIgnore + onNotExistAdd +) + +type destAbsPathInfosOptions struct { + follow bool + onIgnoreFunc func(chezmoi.RelPath) + onNotExist onNotExist + recursive bool +} + +// destAbsPathInfos returns the os/fs.FileInfos for each destination entry in +// args, recursing into subdirectories and following symlinks if configured in +// options. +func (c *Config) destAbsPathInfos( + sourceState *chezmoi.SourceState, + args []string, + options destAbsPathInfosOptions, +) (map[chezmoi.AbsPath]fs.FileInfo, error) { + destAbsPathInfos := make(map[chezmoi.AbsPath]fs.FileInfo) + for _, arg := range args { + arg = filepath.Clean(arg) + destAbsPath, err := chezmoi.NewAbsPathFromExtPath(arg, c.homeDirAbsPath) + if err != nil { + return nil, err + } + targetRelPath, err := c.targetRelPath(destAbsPath) + if err != nil { + return nil, err + } + if sourceState.Ignore(targetRelPath) { + options.onIgnoreFunc(targetRelPath) + continue + } + if options.recursive { + walkFunc := func(destAbsPath chezmoi.AbsPath, fileInfo fs.FileInfo, err error) error { + switch { + case errors.Is(err, fs.ErrNotExist): + switch options.onNotExist { + case onNotExistError: + return err + case onNotExistIgnore: + case onNotExistAdd: + if _, ok := destAbsPathInfos[destAbsPath]; !ok { + destAbsPathInfos[destAbsPath] = nil + } + } + return nil + case err != nil: + return err + } + + targetRelPath, err := c.targetRelPath(destAbsPath) + if err != nil { + return err + } + if sourceState.Ignore(targetRelPath) { + options.onIgnoreFunc(targetRelPath) + if fileInfo.IsDir() { + return fs.SkipDir + } + return nil + } + + if options.follow && fileInfo.Mode().Type() == fs.ModeSymlink { + fileInfo, err = c.destSystem.Stat(destAbsPath) + if err != nil { + return err + } + } + + return sourceState.AddDestAbsPathInfos(destAbsPathInfos, c.destSystem, destAbsPath, fileInfo) + } + if err := chezmoi.Walk(c.destSystem, destAbsPath, walkFunc); err != nil { + return nil, err + } + } else { + var fileInfo fs.FileInfo + if options.follow { + fileInfo, err = c.destSystem.Stat(destAbsPath) + } else { + fileInfo, err = c.destSystem.Lstat(destAbsPath) + } + switch { + case errors.Is(err, fs.ErrNotExist): + switch options.onNotExist { + case onNotExistError: + return nil, err + case onNotExistIgnore: + case onNotExistAdd: + if _, ok := destAbsPathInfos[destAbsPath]; !ok { + destAbsPathInfos[destAbsPath] = nil + } + } + continue + case err != nil: + return nil, err + } + if err := sourceState.AddDestAbsPathInfos(destAbsPathInfos, c.destSystem, destAbsPath, fileInfo); err != nil { + return nil, err + } + } + } + return destAbsPathInfos, nil +} + +// diffFile outputs the diff between fromAbsPath, fromData and fromMode and +// toAbsPath, toData and toMode at relPath. +func (c *Config) diffFile( + relPath chezmoi.RelPath, + fromAbsPath chezmoi.AbsPath, fromData []byte, fromMode fs.FileMode, + toAbsPath chezmoi.AbsPath, toData []byte, toMode fs.FileMode, +) error { + if c.useBuiltinDiff || c.Diff.Command == "" { + return c.builtinDiffFile(relPath, fromData, fromMode, toData, toMode) + } + return c.externalDiffFile( + relPath, + fromAbsPath, fromData, fromMode, + toAbsPath, toData, toMode, + ) +} + +// editor returns the path to the user's editor and any extra arguments. +func (c *Config) editor(args []string) (string, []string, error) { + editCommand := c.Edit.Command + editArgs := c.Edit.Args + + // If the user has set an edit command then use it. + if editCommand != "" { + return editCommand, append(editArgs, args...), nil + } + + // Prefer $VISUAL over $EDITOR and fallback to the OS's default editor. + editCommand = cmp.Or(os.Getenv("VISUAL"), os.Getenv("EDITOR"), defaultEditor) + + return parseCommand(editCommand, append(editArgs, args...)) +} + +// errorf writes an error to stderr. +func (c *Config) errorf(format string, args ...any) { + _, _ = fmt.Fprintf(c.stderr, "chezmoi: "+format, args...) +} + +// execute creates a new root command and executes it with args. +func (c *Config) execute(args []string) error { + rootCmd, err := c.newRootCmd() + if err != nil { + return err + } + defer c.finalize() + + rootCmd.SetArgs(args) + + return rootCmd.Execute() +} + +func (c *Config) externalDiffFile( + relPath chezmoi.RelPath, + fromAbsPath chezmoi.AbsPath, fromData []byte, fromMode fs.FileMode, + toAbsPath chezmoi.AbsPath, toData []byte, toMode fs.FileMode, +) error { + if fromAbsPath.IsEmpty() { + fromTempDir, err := c.tempDir("chezmoi-diff-from") + if err != nil { + return err + } + fromAbsPath = fromTempDir.Join(relPath) + if err := os.MkdirAll(fromAbsPath.Dir().String(), 0o700); err != nil { + return err + } + if err := os.WriteFile(fromAbsPath.String(), fromData, fromMode); err != nil { + return err + } + } + + if toAbsPath.IsEmpty() { + toTempDir, err := c.tempDir("chezmoi-diff-to") + if err != nil { + return err + } + toAbsPath = toTempDir.Join(relPath) + if err := os.MkdirAll(toAbsPath.Dir().String(), 0o700); err != nil { + return err + } + if err := os.WriteFile(toAbsPath.String(), toData, toMode); err != nil { + return err + } + } + + externalDiffSystem := c.newExternalDiffSystem(c.baseSystem) + return externalDiffSystem.RunDiffCommand(fromAbsPath, toAbsPath) +} + +// filterInput reads from args (or the standard input if args is empty), +// transforms it with f, and writes the output. +func (c *Config) filterInput(args []string, f func([]byte) ([]byte, error)) error { + if len(args) == 0 { + input, err := io.ReadAll(c.stdin) + if err != nil { + return err + } + output, err := f(input) + if err != nil { + return err + } + return c.writeOutput(output, 0o666) + } + + for _, arg := range args { + argAbsPath, err := chezmoi.NewAbsPathFromExtPath(arg, c.homeDirAbsPath) + if err != nil { + return err + } + input, err := c.baseSystem.ReadFile(argAbsPath) + if err != nil { + return err + } + output, err := f(input) + if err != nil { + return err + } + if err := c.writeOutput(output, 0o666); err != nil { + return err + } + } + + return nil +} + +// finalize cleans up. +func (c *Config) finalize() { + if c.persistentState != nil { + if err := c.persistentState.Close(); err != nil { + c.errorf("error: failed to close persistent state: %v\n", err) + } + } + + // Wait for any diff pager process to terminate. + if c.diffPagerCmd != nil { + if err := c.diffPagerCmdStdin.Close(); err != nil { + c.errorf("error: failed to close diff pager stdin: %v\n", err) + } + if c.diffPagerCmd.Process != nil { + if err := chezmoilog.LogCmdWait(c.logger, c.diffPagerCmd); err != nil { + c.errorf("error: failed to wait for diff pager to close: %v\n", err) + } + } + } + + if c.restoreWindowsConsole != nil { + if err := c.restoreWindowsConsole(); err != nil { + c.errorf("error: failed to restore console: %v\n", err) + } + } + + // Lock Bitwarden CLI. + if err := c.bitwardenLock(); err != nil { + c.errorf("error: failed to lock bitwarden-cli: %v\n", err) + } + + // Close any connection to keepassxc-cli. + if err := c.keepassxcClose(); err != nil { + c.errorf("error: failed to close connection to keepassxc-cli: %v\n", err) + } +} + +type configTemplate struct { + sourceAbsPath chezmoi.AbsPath + format chezmoi.Format + targetRelPath chezmoi.RelPath + contents []byte +} + +// findConfigTemplate searches for a config template, returning the path, +// format, and contents. It returns an error if multiple config file templates +// are found. +func (c *Config) findConfigTemplate() (*configTemplate, error) { + sourceDirAbsPath, err := c.getSourceDirAbsPath(nil) + if err != nil { + return nil, err + } + + dirEntries, err := c.baseSystem.ReadDir(sourceDirAbsPath) + switch { + case errors.Is(err, fs.ErrNotExist): + return nil, nil + case err != nil: + return nil, err + } + + dirEntryNames := chezmoiset.NewWithCapacity[chezmoi.RelPath](len(dirEntries)) + for _, dirEntry := range dirEntries { + dirEntryNames.Add(chezmoi.NewRelPath(dirEntry.Name())) + } + + var configTemplates []*configTemplate //nolint:prealloc + for _, extension := range chezmoi.FormatExtensions { + relPath := chezmoi.NewRelPath(chezmoi.Prefix + "." + extension + chezmoi.TemplateSuffix) + if !dirEntryNames.Contains(relPath) { + continue + } + absPath := sourceDirAbsPath.Join(relPath) + contents, err := c.baseSystem.ReadFile(absPath) + if err != nil { + return nil, err + } + configTemplate := &configTemplate{ + targetRelPath: chezmoi.NewRelPath("chezmoi." + extension), + format: chezmoi.FormatsByExtension[extension], + sourceAbsPath: absPath, + contents: contents, + } + configTemplates = append(configTemplates, configTemplate) + } + + switch len(configTemplates) { + case 0: + return nil, nil + case 1: + return configTemplates[0], nil + default: + sourceAbsPathStrs := make([]string, len(configTemplates)) + for i, configTemplate := range configTemplates { + sourceAbsPathStr := configTemplate.sourceAbsPath.String() + sourceAbsPathStrs[i] = sourceAbsPathStr + } + return nil, fmt.Errorf("multiple config file templates: %s ", english.OxfordWordSeries(sourceAbsPathStrs, "and")) + } +} + +func (c *Config) getBetterleaksDetector() (*detect.Detector, error) { + if c.betterleaksDetector == nil && c.betterleaksDetectorErr == nil { + c.betterleaksDetector, c.betterleaksDetectorErr = detect.NewDetectorDefaultConfig() + } + return c.betterleaksDetector, c.betterleaksDetectorErr +} + +func (c *Config) getConfigFileAbsPath() (chezmoi.AbsPath, error) { + if c.customConfigFileAbsPath.IsEmpty() { + return c.defaultConfigFileAbsPath, c.defaultConfigFileAbsPathErr + } + return c.customConfigFileAbsPath, nil +} + +// getDiffPager returns the pager for diff output. +func (c *Config) getDiffPager() (command string, args []string) { + switch { + case c.noPager: + return "", nil + case c.Diff.Pager != defaultSentinel: + return c.Diff.Pager, c.Diff.PagerArgs + default: + return c.Pager, c.PagerArgs + } +} + +// getDiffPagerCmd returns a command to run the diff pager, or nil if there is +// no diff pager configured. +func (c *Config) getDiffPagerCmd() (*exec.Cmd, error) { + pager, pagerArgs := c.getDiffPager() + if pager == "" { + return nil, nil + } + + // If we're not on Windows and the pager command contains any spaces, assume + // that it is a full shell command to be executed via the user's shell. + // Otherwise, execute it directly. + var pagerCmd *exec.Cmd + if runtime.GOOS != "windows" && whitespaceRx.MatchString(pager) { + shellCommand, _ := shell.CurrentUserShell() + shellCommand, shellArgs, err := parseCommand(shellCommand, []string{"-c", pager}) + if err != nil { + return nil, err + } + pagerCmd = exec.Command(shellCommand, slices.Concat(shellArgs, pagerArgs)...) + } else { + pagerCmd = exec.Command(pager, pagerArgs...) + } + pagerCmd.Stdout = c.stdout + pagerCmd.Stderr = c.stderr + // If the LESS or LV environment variables are not set, set them to sensible + // defaults the same way the git does. See + // https://git-scm.com/docs/git-config#Documentation/git-config.txt-corepager. + if _, ok := os.LookupEnv("LESS"); !ok { + pagerCmd.Env = append(pagerCmd.Environ(), "LESS=FRX") + } + if _, ok := os.LookupEnv("LV"); !ok { + pagerCmd.Env = append(pagerCmd.Environ(), "LV=-c") + } + return pagerCmd, nil +} + +// A modifyHTTPRequestFunc is a function that modifies a [net/http.Request] +// before it is sent. +type modifyHTTPRequestFunc func(*http.Request) (*http.Request, error) + +// A modifyHTTPRequestRoundTripper is a [net/http.Transport] that modifies the +// request before it is sent. +type modifyHTTPRequestRoundTripper struct { + modifyHTTPRequestFunc modifyHTTPRequestFunc + httpRoundTripper http.RoundTripper +} + +func newModifyHTTPRequestRoundTripper(f modifyHTTPRequestFunc, t http.RoundTripper) modifyHTTPRequestRoundTripper { + return modifyHTTPRequestRoundTripper{ + modifyHTTPRequestFunc: f, + httpRoundTripper: t, + } +} + +// RoundTrip implements [net/http.Transport.RoundTrip]. +func (m modifyHTTPRequestRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + modifiedRequest, err := m.modifyHTTPRequestFunc(req) + if err != nil { + return nil, err + } + return m.httpRoundTripper.RoundTrip(modifiedRequest) +} + +func (c *Config) getHTTPClient() (*http.Client, error) { + if c.httpClient != nil { + return c.httpClient, nil + } + + cacheBasePath, err := c.baseSystem.RawPath(c.CacheDirAbsPath) + if err != nil { + return nil, err + } + + c.httpClient = &http.Client{ + Transport: httpcache.NewTransport( + httpCacheScheme+"://"+cacheBasePath.String(), + httpcache.WithLogger(c.logger), + httpcache.WithUpstream(newModifyHTTPRequestRoundTripper( + func(req *http.Request) (*http.Request, error) { + req = req.Clone(req.Context()) + req.Header.Add("User-Agent", "chezmoi.io/"+c.version.String()) + return req, nil + }, + http.DefaultTransport, + )), + ), + } + + return c.httpClient, nil +} + +type getSourceDirAbsPathOptions struct { + refresh bool +} + +// getSourceDirAbsPath returns the source directory, using .chezmoiroot if it +// exists. +func (c *Config) getSourceDirAbsPath(options *getSourceDirAbsPathOptions) (chezmoi.AbsPath, error) { + if options == nil || !options.refresh { + if !c.sourceDirAbsPath.IsEmpty() || c.sourceDirAbsPathErr != nil { + return c.sourceDirAbsPath, c.sourceDirAbsPathErr + } + } + + switch data, err := c.sourceSystem.ReadFile(c.SourceDirAbsPath.JoinString(chezmoi.RootName)); { + case errors.Is(err, fs.ErrNotExist): + c.sourceDirAbsPath = c.SourceDirAbsPath + case err != nil: + c.sourceDirAbsPathErr = err + default: + rootRelPath, err := chezmoi.NewUntrustedRelPath(string(bytes.TrimSpace(data))) + if err != nil { + return chezmoi.EmptyAbsPath, fmt.Errorf("%s: %w", chezmoi.RootName, err) + } + c.sourceDirAbsPath = c.SourceDirAbsPath.Join(rootRelPath) + } + + return c.sourceDirAbsPath, c.sourceDirAbsPathErr +} + +func (c *Config) getSourceState(ctx context.Context, cmd *cobra.Command) (*chezmoi.SourceState, error) { + if c.sourceState != nil || c.sourceStateErr != nil { + return c.sourceState, c.sourceStateErr + } + c.sourceState, c.sourceStateErr = c.newSourceState(ctx, cmd) + return c.sourceState, c.sourceStateErr +} + +// getTemplateData returns the default template data. +func (c *Config) getTemplateData(cmd *cobra.Command) *templateData { + if c.templateData == nil { + c.templateData = c.newTemplateData(cmd) + } + return c.templateData +} + +// getTemplateDataMap returns the template data as a map. +func (c *Config) getTemplateDataMap(cmd *cobra.Command) map[string]any { + templateData := c.getTemplateData(cmd) + + return map[string]any{ + "chezmoi": map[string]any{ + "arch": templateData.arch, + "args": templateData.args, + "cacheDir": templateData.cacheDir, + "command": templateData.command, + "commandDir": templateData.commandDir, + "config": templateData.config, + "configFile": templateData.configFile, + "destDir": templateData.destDir, + "executable": templateData.executable, + "flags": map[string]any{ + "dryRun": templateData.flags.dryRun, + "force": templateData.flags.force, + "keepGoing": templateData.flags.keepGoing, + "noPager": templateData.flags.noPager, + "noTTY": templateData.flags.noTTY, + }, + "fqdnHostname": templateData.fqdnHostname, + "gid": templateData.gid, + "group": templateData.group, + "homeDir": templateData.homeDir, + "hostname": templateData.hostname, + "kernel": templateData.kernel, + "os": templateData.os, + "osRelease": templateData.osRelease, + "pathListSeparator": templateData.pathListSeparator, + "pathSeparator": templateData.pathSeparator, + "rawHomeDir": templateData.rawHomeDir, + "sourceDir": templateData.sourceDir, + "uid": templateData.uid, + "username": templateData.username, + "version": templateData.version, + "windowsVersion": templateData.windowsVersion, + "workingTree": templateData.workingTree, + }, + } +} + +// gitAdd adds all changes to the git index and returns the new git status. +func (c *Config) gitAdd() (*chezmoigit.Status, error) { + if err := c.run(c.SourceDirAbsPath, c.Git.Command, []string{"add", "."}); err != nil { + return nil, err + } + output, err := c.cmdOutput(c.SourceDirAbsPath, c.Git.Command, []string{"status", "--porcelain=v2"}) + if err != nil { + return nil, err + } + return chezmoigit.ParseStatusPorcelainV2(output) +} + +// gitCommit commits all changes in the git index, including generating a +// commit message from status. +func (c *Config) gitCommit(cmd *cobra.Command, status *chezmoigit.Status) error { + if status.IsEmpty() { + return nil + } + if err := c.runHookPre("git-auto-commit"); err != nil { + return err + } + commitMessage, err := c.gitCommitMessage(cmd, status) + if err != nil { + return err + } + if err := c.run(c.SourceDirAbsPath, c.Git.Command, []string{"commit", "--message", string(commitMessage)}); err != nil { + return err + } + return c.runHookPost("git-auto-commit") +} + +// gitCommitMessage returns the git commit message for the given status. +func (c *Config) gitCommitMessage(cmd *cobra.Command, status *chezmoigit.Status) ([]byte, error) { + templateFuncs := maps.Clone(c.templateFuncs) + maps.Copy(templateFuncs, map[string]any{ + "promptBool": c.promptBoolInteractiveTemplateFunc, + "promptChoice": c.promptChoiceInteractiveTemplateFunc, + "promptInt": c.promptIntInteractiveTemplateFunc, + "promptMultichoice": c.promptMultichoiceInteractiveTemplateFunc, + "promptString": c.promptStringInteractiveTemplateFunc, + "targetRelPath": func(source string) string { + return mustValue(chezmoi.NewSourceRelPath(source).TargetRelPath(c.encryption.EncryptedSuffix())).String() + }, + }) + var name string + var commitMessageTemplateData []byte + switch { + case c.Git.CommitMessageTemplate != "": + name = "git.commitMessageTemplate" + commitMessageTemplateData = []byte(c.Git.CommitMessageTemplate) + case c.Git.CommitMessageTemplateFile != "": + if c.sourceDirAbsPathErr != nil { + return nil, c.sourceDirAbsPathErr + } + commitMessageTemplateFileAbsPath := c.sourceDirAbsPath.JoinString(c.Git.CommitMessageTemplateFile) + name = c.sourceDirAbsPath.String() + var err error + commitMessageTemplateData, err = c.baseSystem.ReadFile(commitMessageTemplateFileAbsPath) + if err != nil { + return nil, err + } + default: + name = "COMMIT_MESSAGE" + commitMessageTemplateData = []byte(templates.CommitMessageTmpl) + } + commitMessageTmpl, err := chezmoi.ParseTemplate(name, commitMessageTemplateData, chezmoi.TemplateOptions{ + Funcs: templateFuncs, + Options: slices.Clone(c.Template.Options), + }) + if err != nil { + return nil, err + } + sourceState, err := c.getSourceState(cmd.Context(), cmd) + if err != nil { + return nil, err + } + templateDataMap := sourceState.TemplateData() + templateDataMap["chezmoi"].(map[string]any)["status"] = status //nolint:forcetypeassert + return commitMessageTmpl.Execute(templateDataMap) +} + +// gitPush pushes all changes to the remote if status is not empty. +func (c *Config) gitPush(status *chezmoigit.Status) error { + if status.IsEmpty() { + return nil + } + if err := c.runHookPre("git-auto-push"); err != nil { + return err + } + if err := c.run(c.SourceDirAbsPath, c.Git.Command, []string{"push"}); err != nil { + return err + } + return c.runHookPost("git-auto-push") +} + +// makeRunEWithSourceState returns a function for +// github.com/spf13/cobra.Command.RunE that includes reading the source state. +func (c *Config) makeRunEWithSourceState( + runE func(*cobra.Command, []string, *chezmoi.SourceState) error, +) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + sourceState, err := c.getSourceState(cmd.Context(), cmd) + if err != nil { + return err + } + return runE(cmd, args, sourceState) + } +} + +// marshal formats data in dataFormat and writes it to the standard output. +func (c *Config) marshal(dataFormat string, data any) error { + var format chezmoi.Format + switch dataFormat { + case formatJSON: + format = chezmoi.FormatJSON + case formatYAML: + format = chezmoi.FormatYAML + default: + return fmt.Errorf("%s: invalid format", dataFormat) + } + marshaledData, err := format.Marshal(data) + if err != nil { + return err + } + return c.writeOutput(marshaledData, 0o666) +} + +// newBuiltinDifSystem returns a new builtin diff system. +func (c *Config) newBuiltinDiffSystem(s chezmoi.System, w io.Writer, dirAbsPath chezmoi.AbsPath) *chezmoi.GitDiffSystem { + options := &chezmoi.GitDiffSystemOptions{ + Color: c.Color.Value(c.colorAutoFunc), + 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.NewGitDiffSystem(s, w, dirAbsPath, options) +} + +// newDiffSystem returns a system that logs all changes to s to w using +// diff.command if set or the builtin git diff otherwise. +func (c *Config) newDiffSystem(s chezmoi.System, w io.Writer, dirAbsPath chezmoi.AbsPath) chezmoi.System { + if c.useBuiltinDiff || c.Diff.Command == "" { + return c.newBuiltinDiffSystem(s, w, dirAbsPath) + } + return c.newExternalDiffSystem(s) +} + +// newExternalDiffSystem returns a new external diff system. +func (c *Config) newExternalDiffSystem(s chezmoi.System) *chezmoi.ExternalDiffSystem { + options := &chezmoi.ExternalDiffSystemOptions{ + Filter: chezmoi.NewEntryTypeFilter(c.Diff.include.Bits(), c.Diff.Exclude.Bits()), + Reverse: c.Diff.Reverse, + ScriptContents: c.Diff.ScriptContents, + TemplateOptions: chezmoi.TemplateOptions{ + Funcs: c.templateFuncs, + Options: c.Template.Options, + }, + TextConvFunc: c.TextConv.convert, + } + return chezmoi.NewExternalDiffSystem(s, c.Diff.Command, c.Diff.Args, c.DestDirAbsPath, c.getDiffPagerCmd, options) +} + +// newRootCmd returns a new root github.com/spf13/cobra.Command. +func (c *Config) newRootCmd() (*cobra.Command, error) { + rootCmd := &cobra.Command{ + Use: "chezmoi", + Short: "Manage your dotfiles across multiple diverse machines, securely", + Version: c.versionStr, + PersistentPreRunE: c.persistentPreRunRootE, + PersistentPostRunE: c.persistentPostRunRootE, + SilenceErrors: true, + SilenceUsage: true, + } + rootCmd.AddGroup(groups...) + + persistentFlags := rootCmd.PersistentFlags() + + persistentFlags.StringVar(&c.ageRecipient, "age-recipient", c.ageRecipient, "Override age recipient") + persistentFlags.StringVar(&c.ageRecipientFile, "age-recipient-file", c.ageRecipient, "Override age recipient") + persistentFlags.Var(&c.CacheDirAbsPath, "cache", "Set cache directory") + persistentFlags.Var(&c.Color, "color", "Colorize output") + persistentFlags.VarP(&c.DestDirAbsPath, "destination", "D", "Set destination directory") + persistentFlags.BoolVar(&c.Interactive, "interactive", c.Interactive, "Prompt for all changes") + persistentFlags.BoolVar( + &c.LessInteractive, + "less-interactive", + c.LessInteractive, + "Prompt for changed or pre-existing targets", + ) + persistentFlags.Var(&c.Mode, "mode", "Mode") + persistentFlags.Var(&c.PersistentStateAbsPath, "persistent-state", "Set persistent state file") + persistentFlags.Var(&c.Progress, "progress", "Display progress bars") + persistentFlags.BoolVar(&c.Safe, "safe", c.Safe, "Safely replace files and symlinks") + persistentFlags.VarP(&c.SourceDirAbsPath, "source", "S", "Set source directory") + persistentFlags.Var(&c.UseBuiltinAge, "use-builtin-age", "Use builtin age") + persistentFlags.Var(&c.UseBuiltinGit, "use-builtin-git", "Use builtin git") + persistentFlags.BoolVarP(&c.Verbose, "verbose", "v", c.Verbose, "Make output more verbose") + persistentFlags.VarP(&c.WorkingTreeAbsPath, "working-tree", "W", "Set working tree directory") + + persistentFlags.VarP(&c.customConfigFileAbsPath, "config", "c", "Set config file") + persistentFlags.Var(c.configFormat, "config-format", "Set config file format") + persistentFlags.BoolVar(&c.debug, "debug", c.debug, "Include debug information in output") + persistentFlags.BoolVarP(&c.dryRun, "dry-run", "n", c.dryRun, "Do not make any modifications to the destination directory") + persistentFlags.BoolVar(&c.force, "force", c.force, "Make all changes without prompting") + persistentFlags.BoolVarP(&c.keepGoing, "keep-going", "k", c.keepGoing, "Keep going as far as possible after an error") + persistentFlags.BoolVar(&c.noPager, "no-pager", c.noPager, "Do not use the pager") + persistentFlags.BoolVar(&c.noTTY, "no-tty", c.noTTY, "Do not attempt to get a TTY for prompts") + persistentFlags.VarP(&c.outputAbsPath, "output", "o", "Write output to path instead of stdout") + persistentFlags.StringVar(&c.overrideData, "override-data", c.overrideData, "Override data") + persistentFlags.Var(&c.overrideDataFileAbsPath, "override-data-file", "Override data with file") + persistentFlags.VarP(&c.refreshExternals, "refresh-externals", "R", "Refresh external cache") + persistentFlags.Lookup("refresh-externals").NoOptDefVal = chezmoi.RefreshExternalsAlways.String() + persistentFlags.BoolVar(&c.skipSecrets, "skip-secrets", c.skipSecrets, "Skip all templates containing secrets") + persistentFlags.BoolVar(&c.sourcePath, "source-path", c.sourcePath, "Specify targets by source path") + persistentFlags.BoolVar(&c.errorOnConflict, "error-on-conflict", c.errorOnConflict, "Error on conflict") + persistentFlags.BoolVarP(&c.useBuiltinDiff, "use-builtin-diff", "", c.useBuiltinDiff, "Use builtin diff") + + if err := chezmoierrors.Combine( + rootCmd.MarkPersistentFlagFilename("config"), + rootCmd.MarkPersistentFlagDirname("destination"), + rootCmd.MarkPersistentFlagFilename("output"), + persistentFlags.MarkHidden("safe"), + rootCmd.MarkPersistentFlagDirname("source"), + rootCmd.RegisterFlagCompletionFunc("color", autoBoolFlagCompletionFunc), + rootCmd.RegisterFlagCompletionFunc("config-format", c.configFormat.FlagCompletionFunc()), + rootCmd.RegisterFlagCompletionFunc("mode", chezmoi.ModeFlagCompletionFunc), + rootCmd.RegisterFlagCompletionFunc("progress", autoBoolFlagCompletionFunc), + rootCmd.RegisterFlagCompletionFunc("refresh-externals", chezmoi.RefreshExternalsFlagCompletionFunc), + rootCmd.RegisterFlagCompletionFunc("use-builtin-age", autoBoolFlagCompletionFunc), + rootCmd.RegisterFlagCompletionFunc("use-builtin-git", autoBoolFlagCompletionFunc), + rootCmd.MarkPersistentFlagDirname("working-tree"), + ); err != nil { + return nil, err + } + + rootCmd.SetHelpCommand(c.newHelpCmd()) + for _, cmd := range []*cobra.Command{ + c.newAddCmd(), + c.newAgeCmd(), + c.newAgeKeygenCmd(), + c.newApplyCmd(), + c.newArchiveCmd(), + c.newCatCmd(), + c.newCatConfigCmd(), + c.newCDCmd(), + c.newChattrCmd(), + c.newCompletionCmd(), + c.newDataCmd(), + c.newDecryptCommand(), + c.newDestroyCmd(), + c.newDiffCmd(), + c.newDockerCmd(), + c.newDoctorCmd(), + c.newDumpCmd(), + c.newDumpConfigCmd(), + c.newEditCmd(), + c.newEditConfigCmd(), + c.newEditConfigTemplateCmd(), + c.newEditEncryptedCmd(), + c.newEncryptCmd(), + c.newExecuteTemplateCmd(), + c.newForgetCmd(), + c.newGenerateCmd(), + c.newGitCmd(), + c.newIgnoredCmd(), + c.newImportCmd(), + c.newInitCmd(), + c.newInternalTestCmd(), + c.newLicenseCmd(), + c.newMackupCmd(), + c.newManagedCmd(), + c.newMergeCmd(), + c.newMergeAllCmd(), + c.newPurgeCmd(), + c.newPushCmd(), + c.newReAddCmd(), + c.newRemoveCmd(), + c.newSSHCmd(), + c.newSecretCmd(), + c.newSourcePathCmd(), + c.newStateCmd(), + c.newStatusCmd(), + c.newTargetPathCmd(), + c.newUnmanagedCmd(), + c.newUpdateCmd(), + c.newUpgradeCmd(), + c.newVerifyCmd(), + } { + if cmd != nil { + ensureHasGroupID(cmd) + ensureAllFlagsDocumented(cmd, persistentFlags) + registerCommonFlagCompletionFuncs(cmd) + rootCmd.AddCommand(cmd) + } + } + + for _, cmd := range rootCmd.Commands() { + annotations := getAnnotations(cmd) + if len(annotations) == 0 { + panic(cmd.Name() + ": no annotations") + } + if annotations.persistentStateMode() == "" { + panic(cmd.Name() + ": persistent state mode not set") + } + } + + return rootCmd, nil +} + +// newSourceState returns a new SourceState with options. +func (c *Config) newSourceState( + ctx context.Context, + cmd *cobra.Command, + options ...chezmoi.SourceStateOption, +) (*chezmoi.SourceState, error) { + if err := c.checkVersion(); err != nil { + return nil, err + } + + httpClient, err := c.getHTTPClient() + if err != nil { + return nil, err + } + + sourceStateLogger := c.logger.With(slog.String(logComponentKey, logComponentValueSourceState)) + + c.SourceDirAbsPath, err = c.getSourceDirAbsPath(nil) + if err != nil { + return nil, err + } + + if err := c.runHookPre(readSourceStateHookName); err != nil { + return nil, err + } + + priorityTemplateData := c.Data + if !c.overrideDataFileAbsPath.IsEmpty() { + var overrideData map[string]any + data, err := c.baseSystem.ReadFile(c.overrideDataFileAbsPath) + if err != nil { + return nil, err + } + if err := chezmoi.UnmarshalFileData(c.overrideDataFileAbsPath, data, &overrideData); err != nil { + return nil, err + } + chezmoi.RecursiveMerge(priorityTemplateData, overrideData) + } + if c.overrideData != "" { + var overrideData map[string]any + if err := json.Unmarshal([]byte(c.overrideData), &overrideData); err != nil { + return nil, err + } + chezmoi.RecursiveMerge(priorityTemplateData, overrideData) + } + + sourceState := chezmoi.NewSourceState(append([]chezmoi.SourceStateOption{ + chezmoi.WithBaseSystem(c.baseSystem), + chezmoi.WithCacheDir(c.CacheDirAbsPath), + chezmoi.WithDefaultTemplateDataFunc(func() map[string]any { + return c.getTemplateDataMap(cmd) + }), + chezmoi.WithDestDir(c.DestDirAbsPath), + chezmoi.WithEncryption(c.encryption), + chezmoi.WithHTTPClient(httpClient), + chezmoi.WithInterpreters(c.Interpreters), + chezmoi.WithLogger(sourceStateLogger), + chezmoi.WithMode(c.Mode), + chezmoi.WithPriorityTemplateData(priorityTemplateData), + chezmoi.WithScriptTempDir(c.ScriptTempDir), + chezmoi.WithSourceDir(c.SourceDirAbsPath), + chezmoi.WithSystem(c.sourceSystem), + chezmoi.WithTemplateFuncs(c.templateFuncs), + chezmoi.WithTemplateOptions(c.Template.Options), + chezmoi.WithUmask(c.Umask), + chezmoi.WithVersion(c.version), + chezmoi.WithWarnFunc(c.errorf), + }, options...)...) + + if err := sourceState.Read(ctx, &chezmoi.ReadOptions{ + RefreshExternals: c.refreshExternals, + ReadHTTPResponse: c.readHTTPResponse, + }); err != nil { + return nil, err + } + + if err := c.runHookPost(readSourceStateHookName); err != nil { + return nil, err + } + + return sourceState, nil +} + +func (c *Config) newTemplateData(cmd *cobra.Command) *templateData { + // Determine the user's username and group, if possible. + // + // os/user.Current and os/user.LookupGroupId in Go's standard library are + // generally unreliable, so work around errors if possible, or ignore them. + // + // On Android, user.Current always fails. Instead, use $LOGNAME (as this is + // set by Termux), or $USER if $LOGNAME is not set. + // + // If CGO is disabled, then the Go standard library falls back to parsing + // /etc/passwd and /etc/group, which will return incorrect results without + // error if the system uses an alternative password database such as NIS or + // LDAP. + // + // If CGO is enabled then os/user.Current and os/user.LookupGroupId will use + // the underlying libc functions, namely getpwuid_r and getgrnam_r. If + // linked with glibc this will return the correct result. If linked with + // musl then they will use musl's implementation which, like Go's non-CGO + // implementation, also only parses /etc/passwd and /etc/group and so also + // returns incorrect results without error if NIS or LDAP are being used. + // + // On Windows, the user's group ID returned by os/user.Current() is an SID + // and no further useful lookup is possible with Go's standard library. + // + // If os/user.Current fails, then fallback to $USER. + // + // Since neither the username nor the group are likely widely used in + // templates, leave these variables unset if their values cannot be + // determined. Unset variables will trigger template errors if used, + // alerting the user to the problem and allowing them to find alternative + // solutions. + var gid, group, uid, username string + if runtime.GOOS == "android" { + username = cmp.Or(os.Getenv("LOGNAME"), os.Getenv("USER")) + } else if currentUser, err := user.Current(); err == nil { + gid = currentUser.Gid + uid = currentUser.Uid + username = currentUser.Username + if runtime.GOOS != "windows" { + if rawGroup, err := user.LookupGroupId(currentUser.Gid); err == nil { + group = rawGroup.Name + } else { + c.logger.Info("user.LookupGroupId", slog.Any("err", err), slog.String("gid", currentUser.Gid)) + } + } + } else { + c.logger.Error("user.Current", slog.Any("err", err)) + var ok bool + username, ok = os.LookupEnv("USER") + if !ok { + c.logger.Info("os.LookupEnv", slog.String("key", "USER"), slog.Bool("ok", ok)) + } + } + + fqdnHostname, err := chezmoi.FQDNHostname(c.fileSystem) + if err != nil { + c.logger.Info("chezmoi.FQDNHostname", slog.Any("err", err)) + } + hostname, _, _ := strings.Cut(fqdnHostname, ".") + + kernel, err := chezmoi.Kernel(c.fileSystem) + if err != nil { + c.logger.Info("chezmoi.Kernel", slog.Any("err", err)) + } + + var osRelease map[string]any + switch runtime.GOOS { + case "openbsd", "windows": + // Don't populate osRelease on OSes where /etc/os-release does not + // exist. + default: + if rawOSRelease, err := chezmoi.OSRelease(c.fileSystem); err == nil { + osRelease = upperSnakeCaseToCamelCaseMap(rawOSRelease) + } else { + c.logger.Info("chezmoi.OSRelease", slog.Any("err", err)) + } + } + + rawHomeDir, _ := os.UserHomeDir() + configFileAbsPath, _ := c.getConfigFileAbsPath() + executable, _ := os.Executable() + windowsVersion, _ := windowsVersion() + sourceDirAbsPath, _ := c.getSourceDirAbsPath(nil) + + return &templateData{ + arch: runtime.GOARCH, + args: os.Args, + cacheDir: c.CacheDirAbsPath.String(), + command: cmd.Name(), + commandDir: c.commandDirAbsPath.String(), + config: c.toMap(), + configFile: configFileAbsPath.String(), + destDir: c.DestDirAbsPath.String(), + executable: executable, + flags: templateDataFlags{ + dryRun: c.dryRun, + force: c.force, + keepGoing: c.keepGoing, + noPager: c.noPager, + noTTY: c.noTTY, + }, + fqdnHostname: fqdnHostname, + gid: gid, + group: group, + homeDir: c.homeDirAbsPath.String(), + hostname: hostname, + kernel: kernel, + os: runtime.GOOS, + osRelease: osRelease, + pathListSeparator: string(os.PathListSeparator), + pathSeparator: string(os.PathSeparator), + rawHomeDir: rawHomeDir, + sourceDir: sourceDirAbsPath.String(), + uid: uid, + username: username, + version: map[string]any{ + "builtBy": c.versionInfo.BuiltBy, + "commit": c.versionInfo.Commit, + "date": c.versionInfo.Date, + "version": c.versionInfo.Version, + }, + windowsVersion: windowsVersion, + workingTree: c.WorkingTreeAbsPath.String(), + } +} + +// pageDiffOutput pages the diff output to stdout. +func (c *Config) pageDiffOutput(output string) error { + switch pagerCmd, err := c.getDiffPagerCmd(); { + case err != nil: + return err + case pagerCmd == nil: + return c.writeOutputString(output, 0o666) + default: + pagerCmd.Stdin = bytes.NewBufferString(output) + return chezmoilog.LogCmdRun(c.logger, pagerCmd) + } +} + +// persistentPostRunRootE performs post-run actions for the root command. +func (c *Config) persistentPostRunRootE(cmd *cobra.Command, args []string) error { + annotations := getAnnotations(cmd) + + // Verify modified config. + if annotations.hasTag(modifiesConfigFile) { + configFileAbsPath, err := c.getConfigFileAbsPath() + if err != nil { + return err + } + configFileContents, err := c.baseSystem.ReadFile(configFileAbsPath) + switch { + case errors.Is(err, fs.ErrNotExist): + err = nil + case err != nil: + // err is already set, do nothing. + default: + var format chezmoi.Format + if format, err = chezmoi.FormatFromAbsPath(configFileAbsPath); err == nil { + var config map[string]any + if err = format.Unmarshal(configFileContents, &config); err != nil { + // err is already set, do nothing. + } else { + err = c.decodeConfigMap(config, &ConfigFile{}) + } + } + } + if err != nil { + c.errorf("warning: %s: %v\n", configFileAbsPath, err) + } + } + + // Perform auto git commands. + if annotations.hasTag(modifiesSourceDirectory) { + var status *chezmoigit.Status + if c.Git.AutoAdd || c.Git.AutoCommit || c.Git.AutoPush { + var err error + status, err = c.gitAdd() + if err != nil { + return err + } + } + if c.Git.AutoCommit || c.Git.AutoPush { + if err := c.gitCommit(cmd, status); err != nil { + return err + } + } + if c.Git.AutoPush { + if err := c.gitPush(status); err != nil { + return err + } + } + } + + return c.runHookPost(cmd.Name()) +} + +// persistentPreRunRootE performs pre-run actions for the root command. +func (c *Config) persistentPreRunRootE(cmd *cobra.Command, args []string) error { + annotations := getAnnotations(cmd) + + // Add the completion template function. This needs cmd, so we can't do it + // in newConfig. + c.addTemplateFunc("completion", func(shell string) string { + return mustValue(completion(cmd, shell)) + }) + + if runtime.GOOS == "windows" { + var err error + c.restoreWindowsConsole, err = termenv.EnableVirtualTerminalProcessing(termenv.DefaultOutput()) + if err != nil { + return err + } + } + + // Save flags that were set on the command line. Skip some types as + // spf13/pflag does not round trip them correctly. + changedFlags := make(map[pflag.Value]string) + brokenFlagTypes := map[string]bool{ + "stringToInt": true, + "stringToInt64": true, + "stringToString": true, + } + cmd.Flags().VisitAll(func(flag *pflag.Flag) { + if flag.Changed && !brokenFlagTypes[flag.Value.Type()] { + changedFlags[flag.Value] = flag.Value.String() + } + }) + + // Read the config file. + if annotations.hasTag(doesNotRequireValidConfig) { + if configFileAbsPath, err := c.getConfigFileAbsPath(); err == nil { + _ = c.readConfig(configFileAbsPath) + } + } else { + configFileAbsPath, err := c.getConfigFileAbsPath() + if err != nil { + return err + } + if err := c.readConfig(configFileAbsPath); err != nil { + return fmt.Errorf("invalid config: %s: %w", configFileAbsPath, err) + } + } + + // Restore flags that were set on the command line. + for value, original := range changedFlags { + if err := value.Set(original); err != nil { + return err + } + } + + if c.force && c.Interactive { + return errors.New("the --force and --interactive flags are mutually exclusive") + } + + // Configure the logger. + var handler slog.Handler + if c.debug { + handler = slog.NewTextHandler(c.stderr, nil) + } else { + handler = chezmoilog.NullHandler{} + } + c.logger = slog.New(handler) + slog.SetDefault(c.logger) + + // Log basic information. + c.logger.Info("persistentPreRunRootE", + slog.Any("version", c.versionInfo), + slog.Any("args", os.Args), + slog.String("goVersion", runtime.Version()), + ) + realSystem := chezmoi.NewRealSystem(c.fileSystem, + chezmoi.RealSystemWithSafe(c.Safe), + chezmoi.RealSystemWithScriptTempDir(c.ScriptTempDir), + ) + c.baseSystem = realSystem + if c.debug { + systemLogger := c.logger.With(slog.String(logComponentKey, logComponentValueSystem)) + c.baseSystem = chezmoi.NewDebugSystem(c.baseSystem, systemLogger) + } + + // Set up the persistent state. + switch persistentStateMode := annotations.persistentStateMode(); { + case persistentStateMode == persistentStateModeEmpty: + c.persistentState = chezmoi.NewMockPersistentState() + case persistentStateMode == persistentStateModeReadOnly: + persistentStateFileAbsPath, err := c.persistentStateFile() + if err != nil { + return err + } + c.persistentState, err = chezmoi.NewBoltPersistentState( + c.baseSystem, + persistentStateFileAbsPath, + chezmoi.BoltPersistentStateReadOnly, + ) + if err != nil { + return err + } + case persistentStateMode == persistentStateModeReadMockWrite: + fallthrough + case persistentStateMode == persistentStateModeReadWrite && c.dryRun: + persistentStateFileAbsPath, err := c.persistentStateFile() + if err != nil { + return err + } + persistentState, err := chezmoi.NewBoltPersistentState( + c.baseSystem, + persistentStateFileAbsPath, + chezmoi.BoltPersistentStateReadOnly, + ) + if err != nil { + return err + } + dryRunPersistentState := chezmoi.NewMockPersistentState() + if err := persistentState.CopyTo(dryRunPersistentState); err != nil { + return err + } + if err := persistentState.Close(); err != nil { + return err + } + c.persistentState = dryRunPersistentState + case persistentStateMode == persistentStateModeReadWrite: + persistentStateFileAbsPath, err := c.persistentStateFile() + if err != nil { + return err + } + c.persistentState, err = chezmoi.NewBoltPersistentState( + c.baseSystem, + persistentStateFileAbsPath, + chezmoi.BoltPersistentStateReadWrite, + ) + if err != nil { + return err + } + default: + c.persistentState = chezmoi.NullPersistentState{} + } + if c.debug && c.persistentState != nil { + persistentStateLogger := c.logger.With(slog.String(logComponentKey, logComponentValuePersistentState)) + c.persistentState = chezmoi.NewDebugPersistentState(c.persistentState, persistentStateLogger) + } + + // Set up the source and destination systems. + c.sourceSystem = c.baseSystem + c.destSystem = c.baseSystem + if !annotations.hasTag(modifiesDestinationDirectory) { + c.destSystem = chezmoi.NewReadOnlySystem(c.destSystem) + } + if !annotations.hasTag(modifiesSourceDirectory) { + c.sourceSystem = chezmoi.NewReadOnlySystem(c.sourceSystem) + } + if c.dryRun || annotations.hasTag(dryRun) { + c.sourceSystem = chezmoi.NewDryRunSystem(c.sourceSystem) + c.destSystem = chezmoi.NewDryRunSystem(c.destSystem) + } + if annotations.hasTag(outputsDiff) || + c.Verbose && (annotations.hasTag(modifiesDestinationDirectory) || annotations.hasTag(modifiesSourceDirectory)) { + // If the user has configured a diff pager, then start it as a process. + // Otherwise, write the diff output directly to stdout. + var writer io.Writer + switch pagerCmd, err := c.getDiffPagerCmd(); { + case err != nil: + return err + case pagerCmd == nil: + writer = c.stdout + default: + pipeReader, pipeWriter := io.Pipe() + pagerCmd.Stdin = pipeReader + lazyWriter := newLazyWriter(func() (io.WriteCloser, error) { + if err := chezmoilog.LogCmdStart(c.logger, pagerCmd); err != nil { + return nil, err + } + return pipeWriter, nil + }) + writer = lazyWriter + c.diffPagerCmd = pagerCmd + c.diffPagerCmdStdin = lazyWriter + } + c.sourceSystem = c.newDiffSystem(c.sourceSystem, writer, c.SourceDirAbsPath) + c.destSystem = c.newDiffSystem(c.destSystem, writer, c.DestDirAbsPath) + } + + if err := c.setEncryption(); err != nil && !annotations.hasTag(doesNotRequireValidConfig) { + return err + } + + // Create the config directory if needed. + if annotations.hasTag(requiresConfigDirectory) { + configFileAbsPath, err := c.getConfigFileAbsPath() + if err != nil { + return err + } + if err := chezmoi.MkdirAll(c.baseSystem, configFileAbsPath.Dir(), fs.ModePerm); err != nil { + return err + } + } + + // Create the source directory if needed. + if annotations.hasTag(createSourceDirectoryIfNeeded) { + if err := chezmoi.MkdirAll(c.baseSystem, c.SourceDirAbsPath, fs.ModePerm); err != nil { + return err + } + } + + // Verify that the source directory exists and is a directory, if needed. + if annotations.hasTag(requiresSourceDirectory) { + switch fileInfo, err := c.baseSystem.Stat(c.SourceDirAbsPath); { + case err == nil && fileInfo.IsDir(): + // Do nothing. + case err == nil: + return fmt.Errorf("%s: not a directory", c.SourceDirAbsPath) + default: + return err + } + } + + // Create the runtime directory if needed. + if annotations.hasTag(runsCommands) { + if runtime.GOOS == "linux" && c.bds.RuntimeDir != "" { + // Snap sets the $XDG_RUNTIME_DIR environment variable to + // /run/user/$uid/snap.$snap_name, but does not create this + // directory. Consequently, any spawned processes that need + // $XDG_DATA_DIR will fail. As a work-around, create the directory + // if it does not exist. See + // https://forum.snapcraft.io/t/wayland-dconf-and-xdg-runtime-dir/186/13. + if err := chezmoi.MkdirAll(c.baseSystem, chezmoi.NewAbsPath(c.bds.RuntimeDir), 0o700); err != nil { + return err + } + } + } + + // Determine the working tree directory if it is not configured. + if c.WorkingTreeAbsPath.IsEmpty() { + workingTreeAbsPath := c.SourceDirAbsPath + for { + gitDirAbsPath := workingTreeAbsPath.JoinString(git.GitDirName) + if _, err := c.baseSystem.Stat(gitDirAbsPath); err == nil { + c.WorkingTreeAbsPath = workingTreeAbsPath + break + } + prevWorkingTreeDirAbsPath := workingTreeAbsPath + workingTreeAbsPath = workingTreeAbsPath.Dir() + if workingTreeAbsPath == c.homeDirAbsPath || workingTreeAbsPath.Len() >= prevWorkingTreeDirAbsPath.Len() { + c.WorkingTreeAbsPath = c.SourceDirAbsPath + break + } + } + } + + // Create the working tree directory if needed. + if annotations.hasTag(requiresWorkingTree) { + if _, err := c.SourceDirAbsPath.TrimDirPrefix(c.WorkingTreeAbsPath); err != nil { + return err + } + if err := chezmoi.MkdirAll(c.baseSystem, c.WorkingTreeAbsPath, fs.ModePerm); err != nil { + return err + } + } + + templateData := c.getTemplateData(cmd) + if err := os.Setenv("CHEZMOI", "1"); err != nil { + return err + } + for key, value := range map[string]string{ + "ARCH": templateData.arch, + "ARGS": strings.Join(templateData.args, " "), + "CACHE_DIR": templateData.cacheDir, + "COMMAND": templateData.command, + "COMMAND_DIR": templateData.commandDir, + "CONFIG_FILE": templateData.configFile, + "DEST_DIR": templateData.destDir, + "EXECUTABLE": templateData.executable, + "FQDN_HOSTNAME": templateData.fqdnHostname, + "GID": templateData.gid, + "GROUP": templateData.group, + "HOME_DIR": templateData.homeDir, + "HOSTNAME": templateData.hostname, + "OS": templateData.os, + "RAW_HOME_DIR": templateData.rawHomeDir, + "SOURCE_DIR": templateData.sourceDir, + "UID": templateData.uid, + "USERNAME": templateData.username, + "WORKING_TREE": templateData.workingTree, + } { + if err := os.Setenv("CHEZMOI_"+key, value); err != nil { + return err + } + } + if c.Verbose { + if err := os.Setenv("CHEZMOI_VERBOSE", "1"); err != nil { + return err + } + } + for groupKey, group := range map[string]map[string]any{ + "KERNEL": templateData.kernel, + "OS_RELEASE": templateData.osRelease, + "VERSION": templateData.version, + "WINDOWS_VERSION": templateData.windowsVersion, + } { + for key, value := range group { + key := "CHEZMOI_" + groupKey + "_" + camelCaseToUpperSnakeCase(key) + var valueStr string + switch value := value.(type) { + case string: + valueStr = value + case uint64: + valueStr = strconv.FormatUint(value, 10) + default: + panic(fmt.Errorf("%s has unexpected type %T", key, value)) + } + if err := os.Setenv(key, valueStr); err != nil { + return err + } + } + } + + if err := c.setEnvironmentVariables(); err != nil { + return err + } + + return c.runHookPre(cmd.Name()) +} + +// persistentStateFile returns the absolute path to the persistent state file, +// returning the first persistent file found, and returning the default path if +// none are found. +func (c *Config) persistentStateFile() (chezmoi.AbsPath, error) { + if !c.PersistentStateAbsPath.IsEmpty() { + return c.PersistentStateAbsPath, nil + } + configFileAbsPath, err := c.getConfigFileAbsPath() + if err != nil { + return chezmoi.EmptyAbsPath, err + } + return configFileAbsPath.Dir().Join(persistentStateFileRelPath), nil +} + +// progressAutoFunc detects whether progress bars should be displayed. +func (c *Config) progressAutoFunc() bool { + if stdout, ok := c.stdout.(*os.File); ok { + return term.IsTerminal(int(stdout.Fd())) + } + return false +} + +// readConfig reads the config file, if it exists. +func (c *Config) readConfig(configFileAbsPath chezmoi.AbsPath) error { + switch err := c.decodeConfigFile(configFileAbsPath, &c.ConfigFile); { + case errors.Is(err, fs.ErrNotExist): + return nil + default: + return err + } +} + +// resetSourceState clears the cached source state, if any. +func (c *Config) resetSourceState() { + c.sourceState = nil + c.sourceStateErr = nil +} + +// run runs name with args in dir. +func (c *Config) run(dir chezmoi.AbsPath, name string, args []string) error { + cmd := exec.Command(name, args...) + if !dir.IsEmpty() { + dirRawAbsPath, err := c.baseSystem.RawPath(dir) + if err != nil { + return err + } + cmd.Dir = dirRawAbsPath.String() + } + cmd.Stdin = c.stdin + cmd.Stdout = c.stdout + cmd.Stderr = c.stderr + if err := chezmoilog.LogCmdRun(c.logger, cmd); err != nil { + return fmt.Errorf("%s: %w", name, err) + } + return nil +} + +// runEditor runs the configured editor with args. +func (c *Config) runEditor(args []string) error { + if err := c.persistentState.Close(); err != nil { + return err + } + editor, editorArgs, err := c.editor(args) + if err != nil { + return err + } + start := time.Now() + err = c.run(chezmoi.EmptyAbsPath, editor, editorArgs) + if runtime.GOOS != "windows" && c.Edit.MinDuration != 0 { + if duration := time.Since(start); duration < c.Edit.MinDuration { + c.errorf("warning: %s: returned in less than %s\n", shellQuoteCommand(editor, editorArgs), c.Edit.MinDuration) + } + } + return err +} + +// runHook runs a command or script hook. +func (c *Config) runHook(command commandConfig) error { + var name string + var args []string + switch { + case command.Command != "" && command.Script != "": + return errors.New("cannot specify both command and script") + case command.Command != "": + name = command.Command + args = command.Args + case command.Script != "": + extension := strings.TrimPrefix(strings.ToLower(path.Ext(command.Script)), ".") + if interpreter, ok := c.Interpreters[extension]; ok { + name = interpreter.Command + args = slices.Concat(interpreter.Args, []string{command.Script}, command.Args) + } else { + name = command.Script + args = command.Args + } + default: + return nil + } + return c.run(c.homeDirAbsPath, name, args) +} + +// runHookPost runs the hook's post command, if it is set. +func (c *Config) runHookPost(hook string) error { + if err := c.runHook(c.Hooks[hook].Post); err != nil { + return fmt.Errorf("%s: post: %w", hook, err) + } + return nil +} + +// runHookPre runs the hook's pre command, if it is set. +func (c *Config) runHookPre(hook string) error { + if err := c.runHook(c.Hooks[hook].Pre); err != nil { + return fmt.Errorf("%s: pre: %w", hook, err) + } + return nil +} + +type runInstallInitShellOptions struct { + args []string + interactive bool + packageManager string + shell bool +} + +func (c *Config) runInstallInitShellSh( + sourceState *chezmoi.SourceState, + command string, + commandArgs []string, + options runInstallInitShellOptions, +) error { + script, err := sourceState.ExecuteTemplateData(chezmoi.ExecuteTemplateDataOptions{ + NameRelPath: chezmoi.NewRelPath("install-init-shell.sh.tmpl"), + Data: templates.InstallInitShellShTmpl, + ExtraData: map[string]any{ + "args": options.args, + "interactive": options.interactive, + "packageManager": options.packageManager, + "shell": options.shell, + }, + }) + if err != nil { + return err + } + allArgs := slices.Concat( + commandArgs, + []string{"sh", "-c", string(script)}, + ) + return c.run(chezmoi.EmptyAbsPath, command, allArgs) +} + +// setEncryption configures c's encryption. +func (c *Config) setEncryption() error { + // Override the age recipients for encryption if --age-recipient or + // --age-recipient-file is set. + switch { + case c.ageRecipient != "" && c.ageRecipientFile != "": + return errors.New("--age-recipient and --age-recipient-file cannot both be set") + case c.ageRecipient != "": + c.Age.Recipient = c.ageRecipient + c.Age.Recipients = nil + c.Age.RecipientsFile = chezmoi.EmptyAbsPath + c.Age.RecipientsFiles = nil + case c.ageRecipientFile != "": + c.Age.Recipient = "" + c.Age.Recipients = nil + c.Age.RecipientsFile = chezmoi.NewAbsPath(c.ageRecipientFile) + c.Age.RecipientsFiles = nil + } + + switch c.Encryption { + case "age": + c.Age.UseBuiltin = c.UseBuiltinAge.Value(c.useBuiltinAgeAutoFunc) + c.encryption = &c.Age + case "gpg": + c.encryption = &c.GPG + case "transparent": + c.encryption = chezmoi.TransparentEncryption{} + case "": + // Detect encryption if any non-default configuration is set, preferring + // gpg for backwards compatibility. + switch { + case !reflect.DeepEqual(c.GPG, defaultGPGEncryptionConfig): + c.errorf( + "warning: 'encryption' not set, using gpg configuration. " + + "Check if 'encryption' is correctly set as the top-level key.\n", + ) + c.encryption = &c.GPG + case !reflect.DeepEqual(c.Age, defaultAgeEncryptionConfig): + c.errorf( + "warning: 'encryption' not set, using age configuration. " + + "Check if 'encryption' is correctly set as the top-level key.\n", + ) + c.Age.UseBuiltin = c.UseBuiltinAge.Value(c.useBuiltinAgeAutoFunc) + c.encryption = &c.Age + default: + c.encryption = chezmoi.NoEncryption{} + } + default: + return fmt.Errorf("%s: unknown encryption", c.Encryption) + } + + if c.debug { + encryptionLogger := c.logger.With(slog.String(logComponentKey, logComponentValueEncryption)) + c.encryption = chezmoi.NewDebugEncryption(c.encryption, encryptionLogger) + } + + return nil +} + +// setEnvironmentVariables sets all environment variables defined in c. +func (c *Config) setEnvironmentVariables() error { + var env map[string]string + switch { + case len(c.Env) != 0 && len(c.ScriptEnv) != 0: + return errors.New("only one of env or scriptEnv may be set") + case len(c.Env) != 0: + env = c.Env + case len(c.ScriptEnv) != 0: + env = c.ScriptEnv + } + for key, value := range env { + if strings.HasPrefix(key, "CHEZMOI_") { + c.errorf("warning: %s: overriding reserved environment variable\n", key) + } + if err := os.Setenv(key, value); err != nil { + return err + } + } + return nil +} + +// sourceAbsPaths returns the source absolute paths for each target path in +// args. +func (c *Config) sourceAbsPaths(sourceState *chezmoi.SourceState, args []string) ([]chezmoi.AbsPath, error) { + targetRelPaths, err := c.targetRelPaths(sourceState, args, targetRelPathsOptions{ + mustBeInSourceState: true, + }) + if err != nil { + return nil, err + } + sourceAbsPaths := make([]chezmoi.AbsPath, 0, len(targetRelPaths)) + for _, targetRelPath := range targetRelPaths { + sourceAbsPath := c.SourceDirAbsPath.Join(sourceState.MustEntry(targetRelPath).SourceRelPath().RelPath()) + sourceAbsPaths = append(sourceAbsPaths, sourceAbsPath) + } + return sourceAbsPaths, nil +} + +func (c *Config) targetRelPath(absPath chezmoi.AbsPath) (chezmoi.RelPath, error) { + relPath, err := absPath.TrimDirPrefix(c.DestDirAbsPath) + if notInAbsDirError := (&chezmoi.NotInAbsDirError{}); errors.As(err, ¬InAbsDirError) { + return chezmoi.EmptyRelPath, fmt.Errorf("%s: not in destination directory (%s)", absPath, c.DestDirAbsPath) + } + return relPath, err +} + +type targetRelPathsOptions struct { + mustBeInSourceState bool + mustNotBeExternal bool + recursive bool +} + +// targetRelPaths returns the target relative paths for each target path in +// args. The returned paths are sorted and de-duplicated. +func (c *Config) targetRelPaths( + sourceState *chezmoi.SourceState, + args []string, + options targetRelPathsOptions, +) ([]chezmoi.RelPath, error) { + targetRelPaths := make([]chezmoi.RelPath, 0, len(args)) + for _, arg := range args { + argAbsPath, err := chezmoi.NewAbsPathFromExtPath(arg, c.homeDirAbsPath) + if err != nil { + return nil, err + } + targetRelPath, err := c.targetRelPath(argAbsPath) + if err != nil { + return nil, err + } + sourceStateEntry := sourceState.Get(targetRelPath) + if sourceStateEntry == nil { + return nil, fmt.Errorf("%s: not managed", arg) + } + if options.mustBeInSourceState { + if _, ok := sourceStateEntry.(*chezmoi.SourceStateRemove); ok { + return nil, fmt.Errorf("%s: not in source state", arg) + } + } + if options.mustNotBeExternal { + targetStateEntry, err := sourceStateEntry.TargetStateEntry(c.destSystem, c.DestDirAbsPath.Join(targetRelPath)) + if err != nil { + return nil, err + } + if targetStateEntry.SourceAttr().External { + return nil, fmt.Errorf("%s: is an external", arg) + } + } + targetRelPaths = append(targetRelPaths, targetRelPath) + if options.recursive { + parentRelPath := targetRelPath + // FIXME we should not call s.TargetRelPaths() here - risk of + // accidentally quadratic + for _, targetRelPath := range sourceState.TargetRelPaths() { + if _, err := targetRelPath.TrimDirPrefix(parentRelPath); err == nil { + targetRelPaths = append(targetRelPaths, targetRelPath) + } + } + } + } + + if len(targetRelPaths) == 0 { + return nil, nil + } + + // Sort and de-duplicate targetRelPaths in place. + slices.SortFunc(targetRelPaths, chezmoi.CompareRelPaths) + n := 1 + for i := 1; i < len(targetRelPaths); i++ { + if targetRelPaths[i] != targetRelPaths[i-1] { + targetRelPaths[n] = targetRelPaths[i] + n++ + } + } + return targetRelPaths[:n], nil +} + +// targetRelPathsBySourcePath returns the target relative paths for each arg in +// args. +func (c *Config) targetRelPathsBySourcePath(sourceState *chezmoi.SourceState, args []string) ([]chezmoi.RelPath, error) { + targetRelPaths := make([]chezmoi.RelPath, len(args)) + targetRelPathsBySourceRelPath := make(map[chezmoi.RelPath]chezmoi.RelPath) + _ = sourceState.ForEach( + func(targetRelPath chezmoi.RelPath, sourceStateEntry chezmoi.SourceStateEntry) error { + sourceRelPath := sourceStateEntry.SourceRelPath().RelPath() + targetRelPathsBySourceRelPath[sourceRelPath] = targetRelPath + return nil + }, + ) + for i, arg := range args { + argAbsPath, err := chezmoi.NewAbsPathFromExtPath(arg, c.homeDirAbsPath) + if err != nil { + return nil, err + } + sourceRelPath, err := argAbsPath.TrimDirPrefix(c.SourceDirAbsPath) + if err != nil { + return nil, err + } + targetRelPath, ok := targetRelPathsBySourceRelPath[sourceRelPath] + if !ok { + return nil, fmt.Errorf("%s: not in source state", arg) + } + targetRelPaths[i] = targetRelPath + } + return targetRelPaths, nil +} + +// targetValidArgs returns target completions for toComplete given args. +func (c *Config) targetValidArgs(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if !c.Completion.Custom { + return nil, cobra.ShellCompDirectiveDefault + } + + toCompleteAbsPath, err := chezmoi.NewAbsPathFromExtPath(toComplete, c.homeDirAbsPath) + if err != nil { + cobra.CompErrorln(err.Error()) + return nil, cobra.ShellCompDirectiveError + } + + sourceState, err := c.getSourceState(cmd.Context(), cmd) + if err != nil { + cobra.CompErrorln(err.Error()) + return nil, cobra.ShellCompDirectiveError + } + + var completions []string + if err := sourceState.ForEach(func(targetRelPath chezmoi.RelPath, sourceStateEntry chezmoi.SourceStateEntry) error { + completion := c.DestDirAbsPath.Join(targetRelPath).String() + if _, ok := sourceStateEntry.(*chezmoi.SourceStateDir); ok { + completion += "/" + } + if strings.HasPrefix(completion, toCompleteAbsPath.String()) { + completions = append(completions, completion) + } + return nil + }); err != nil { + cobra.CompErrorln(err.Error()) + return nil, cobra.ShellCompDirectiveError + } + + if !filepath.IsAbs(toComplete) { + for i, completion := range completions { + completions[i] = strings.TrimPrefix(completion, c.commandDirAbsPath.String()+"/") + } + } + + return completions, cobra.ShellCompDirectiveNoFileComp +} + +// tempDir returns the temporary directory for the given key, creating it if +// needed. +func (c *Config) tempDir(key string) (chezmoi.AbsPath, error) { + if tempDirAbsPath, ok := c.tempDirs[key]; ok { + return tempDirAbsPath, nil + } + tempDir, err := os.MkdirTemp(c.TempDir.String(), key) + chezmoilog.InfoOrError(c.logger, "MkdirTemp", err, slog.String("tempDir", tempDir)) + if err != nil { + return chezmoi.EmptyAbsPath, err + } + tempDirAbsPath := chezmoi.NewAbsPath(tempDir) + c.tempDirs[key] = tempDirAbsPath + if runtime.GOOS != "windows" { + if err := os.Chmod(tempDir, 0o700); err != nil { + return chezmoi.EmptyAbsPath, err + } + } + return tempDirAbsPath, nil +} + +// useBuiltinAgeAutoFunc detects whether the builtin age should be used. +func (c *Config) useBuiltinAgeAutoFunc() bool { + if _, err := chezmoi.LookPath(c.Age.Command); err == nil { + return false + } + return true +} + +// useBuiltinGitAutoFunc detects whether the builtin git should be used. +func (c *Config) useBuiltinGitAutoFunc() bool { + if _, err := chezmoi.LookPath(c.Git.Command); err == nil { + return false + } + return true +} + +// writeOutput writes data to the configured output. +func (c *Config) writeOutput(data []byte, perm fs.FileMode) error { + if c.outputAbsPath.IsEmpty() || c.outputAbsPath == chezmoi.NewAbsPath("-") { + _, err := c.stdout.Write(data) + return err + } + file, err := os.OpenFile(c.outputAbsPath.String(), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm) + if err != nil { + return err + } + defer func() { _ = file.Close() }() + fileInfo, err := file.Stat() + if err != nil { + return err + } + if fileInfo.Mode().Perm()&^perm != 0 { + if err := file.Chmod(fileInfo.Mode().Perm() & perm); err != nil { + return err + } + } + _, err = file.Write(data) + return err +} + +// writeOutputString writes data to the configured output. +func (c *Config) writeOutputString(data string, perm fs.FileMode) error { + return c.writeOutput([]byte(data), perm) +} + +type writePathsOptions struct { + nulPathSeparator bool + tree bool +} + +func (c *Config) writePaths(paths []string, options writePathsOptions) error { + pathSeparator := byte('\n') + if options.nulPathSeparator { + pathSeparator = '\x00' + } + builder := strings.Builder{} + if options.tree { + newPathListTreeFromPathsSlice(paths).writeChildren(&builder, "", " ") + } else { + slices.Sort(paths) + for _, path := range paths { + builder.WriteString(path) + builder.WriteByte(pathSeparator) + } + } + return c.writeOutputString(builder.String(), 0o666) +} + +func newConfigFile(bds *xdg.BaseDirectorySpecification) ConfigFile { + return ConfigFile{ + // Global configuration. + CacheDirAbsPath: chezmoi.NewAbsPath(bds.CacheHome).Join(chezmoiRelPath), + Color: autoBool{ + auto: true, + }, + Data: make(map[string]any), + Interpreters: DefaultInterpreters, + Mode: chezmoi.ModeFile, + Pager: os.Getenv("PAGER"), + Progress: autoBool{ + auto: true, + }, + PINEntry: pinEntryConfig{ + Options: pinEntryDefaultOptions, + }, + Safe: true, + TempDir: chezmoi.NewAbsPath(os.TempDir()), + Template: templateConfig{ + Options: chezmoi.DefaultTemplateOptions, + }, + Umask: chezmoi.Umask, + UseBuiltinAge: autoBool{ + auto: true, + }, + UseBuiltinGit: autoBool{ + auto: true, + }, + Warnings: warningsConfig{ + ConfigFileTemplateHasChanged: true, + }, + + // Password manager configurations. + Bitwarden: bitwardenConfig{ + Command: "bw", + }, + BitwardenSecrets: bitwardenSecretsConfig{ + Command: "bws", + }, + Dashlane: dashlaneConfig{ + Command: "dcli", + }, + Doppler: dopplerConfig{ + Command: "doppler", + }, + Ejson: ejsonConfig{ + KeyDir: cmp.Or(os.Getenv("EJSON_KEYDIR"), "/opt/ejson/keys"), + }, + Gopass: gopassConfig{ + Command: "gopass", + }, + Keepassxc: keepassxcConfig{ + Command: "keepassxc-cli", + Prompt: true, + Mode: keepassxcModeCachePassword, + }, + Keeper: keeperConfig{ + Command: "keeper", + }, + Lastpass: lastpassConfig{ + Command: "lpass", + }, + Onepassword: onepasswordConfig{ + Command: "op", + Prompt: true, + Mode: onepasswordModeAccount, + }, + Pass: passConfig{ + Command: "pass", + }, + Passhole: passholeConfig{ + Command: "ph", + Prompt: true, + }, + ProtonPass: protonPassConfig{ + Command: "pass-cli", + }, + RBW: rbwConfig{ + Command: "rbw", + }, + Vault: vaultConfig{ + Command: "vault", + }, + + // Encryption configurations. + Age: defaultAgeEncryptionConfig, + GPG: defaultGPGEncryptionConfig, + + // Command configurations. + Add: addCmdConfig{ + Secrets: newChoiceFlag("warning", severityValues), + filter: chezmoi.NewEntryTypeFilter(chezmoi.EntryTypesAll, chezmoi.EntryTypesNone), + recursive: true, + }, + Diff: diffCmdConfig{ + Exclude: chezmoi.NewEntryTypeSet(chezmoi.EntryTypesNone), + Pager: defaultSentinel, + ScriptContents: true, + include: chezmoi.NewEntryTypeSet(chezmoi.EntryTypesAll), + }, + Docker: dockerCmdConfig{ + Command: "docker", + exec: dockerExecCmdConfig{ + interactive: true, + shell: true, + }, + }, + Edit: editCmdConfig{ + Hardlink: true, + MinDuration: 1 * time.Second, + filter: chezmoi.NewEntryTypeFilter(chezmoi.EntryTypesAll, chezmoi.EntryTypesNone), + }, + Format: newChoiceFlag(formatJSON, readDataFormatValues), + Git: gitCmdConfig{ + Command: "git", + }, + GitHub: gitHubConfig{ + RefreshPeriod: 1 * time.Minute, + }, + Merge: mergeCmdConfig{ + Command: "vimdiff", + }, + Status: statusCmdConfig{ + Exclude: chezmoi.NewEntryTypeSet(chezmoi.EntryTypesNone), + PathStyle: newChoiceFlag(pathStyleRelative, targetPathStyleValues), + include: chezmoi.NewEntryTypeSet(chezmoi.EntryTypesAll), + recursive: true, + }, + Update: updateCmdConfig{ + Apply: true, + RecurseSubmodules: true, + filter: chezmoi.NewEntryTypeFilter(chezmoi.EntryTypesAll, chezmoi.EntryTypesNone), + recursive: true, + }, + Verify: verifyCmdConfig{ + Exclude: chezmoi.NewEntryTypeSet(chezmoi.EntryTypesNone), + include: chezmoi.NewEntryTypeSet(chezmoi.EntryTypesAll), + recursive: true, + }, + } +} + +func (f *ConfigFile) toMap() map[string]any { + // Make a copy of f and replace any default sentinels with the empty string + // to ensure that there are no default sentinels in the result. + configFile := *f + if configFile.Diff.Pager == defaultSentinel { + configFile.Diff.Pager = "" + } + + // This is a horrible hack. We want the returned map to contain only simple + // types because they are used with masterminds/sprig template functions + // which don't accept fmt.Stringers in place of strings. As a work-around, + // round-trip via JSON. + data, err := json.Marshal(configFile) + if err != nil { + return nil + } + var result map[string]any + must(json.Unmarshal(data, &result)) + return result +} + +func parseCommand(command string, args []string) (string, []string, error) { + // If command is found, then return it. + if commandPath, err := chezmoi.LookPath(command); err == nil { + return commandPath, args, nil + } + + // Otherwise, if the command contains spaces, parse it as a shell command. + if whitespaceRx.MatchString(command) { + var words []*syntax.Word + for word, err := range syntax.NewParser().WordsSeq(strings.NewReader(command)) { + if err != nil { + return "", nil, err + } + words = append(words, word) + } + switch fields, err := expand.Fields(&expand.Config{ + Env: expand.FuncEnviron(os.Getenv), + }, words...); { + case err != nil: + return "", nil, err + case len(fields) > 1: + return fields[0], append(fields[1:], args...), nil + case len(fields) == 1: + return fields[0], args, nil + } + } + + // Fallback to the command only. + return command, args, nil +} + +// prependParentRelPaths returns a new slice of RelPaths where the parents of +// each RelPath appear before each RelPath. +func prependParentRelPaths(relPaths []chezmoi.RelPath) []chezmoi.RelPath { + // For each target relative path, enumerate its parents from the root down + // and insert any parents which have not yet been seen. + result := make([]chezmoi.RelPath, 0, len(relPaths)) + seenRelPaths := make(map[chezmoi.RelPath]struct{}, len(relPaths)) + for _, relPath := range relPaths { + components := relPath.SplitAll() + for i := 1; i < len(components); i++ { + parentRelPath := chezmoi.EmptyRelPath.Join(components[:i]...) + if _, ok := seenRelPaths[parentRelPath]; !ok { + result = append(result, parentRelPath) + seenRelPaths[parentRelPath] = struct{}{} + } + } + result = append(result, relPath) + seenRelPaths[relPath] = struct{}{} + } + return result +} + +// registerCommonFlagCompletionFuncs registers completion functions for cmd's +// common flags, recursively. It panics on any error. +func registerCommonFlagCompletionFuncs(cmd *cobra.Command) { + cmd.Flags().VisitAll(func(flag *pflag.Flag) { + if _, exist := cmd.GetFlagCompletionFunc(flag.Name); exist { + return + } + if flagCompletionFunc, ok := commonFlagCompletionFuncs[flag.Name]; ok { + must(cmd.RegisterFlagCompletionFunc(flag.Name, flagCompletionFunc)) + } + }) + for _, command := range cmd.Commands() { + registerCommonFlagCompletionFuncs(command) + } +} + +// sha256Sum returns the SHA256 sum of data. +func sha256Sum(data []byte) []byte { + sha256SumArr := sha256.Sum256(data) + return sha256SumArr[:] +} + +// withVersionInfo sets the version information. +func withVersionInfo(versionInfo VersionInfo) configOption { + return func(c *Config) error { + var version *semver.Version + var versionElems []string + if versionInfo.Version != "" { + var err error + version, err = semver.NewVersion(strings.TrimPrefix(versionInfo.Version, "v")) + if err != nil { + return err + } + versionElems = append(versionElems, "v"+version.String()) + } else { + versionElems = append(versionElems, "dev") + } + if versionInfo.Commit != "" { + versionElems = append(versionElems, "commit "+versionInfo.Commit) + } + if versionInfo.Date != "" { + date := versionInfo.Date + if sec, err := strconv.ParseInt(date, 10, 64); err == nil { + date = time.Unix(sec, 0).UTC().Format(time.RFC3339) + } + versionElems = append(versionElems, "built at "+date) + } + if versionInfo.BuiltBy != "" { + versionElems = append(versionElems, "built by "+versionInfo.BuiltBy) + } + if version != nil { + c.version = *version + } + c.versionInfo = versionInfo + c.versionStr = strings.Join(versionElems, ", ") + return nil + } +} diff --git a/internal/cmd/testdata/scripts/diffcommand_unix.txtar b/internal/cmd/testdata/scripts/diffcommand_unix.txtar new file mode 100644 index 0000000..b2be758 --- /dev/null +++ b/internal/cmd/testdata/scripts/diffcommand_unix.txtar @@ -0,0 +1,120 @@ +[windows] skip 'UNIX only' + +chmod 755 bin/diff-pager + +# test that chezmoi diff invokes diff.command when configured +exec chezmoi diff +stdout ^${HOME@R}/\.file\s+${WORK@R}/.*/\.file$ + +# test that chezmoi diff --use-builtin-diff uses the builtin diff even if diff.command is configured +exec chezmoi diff --use-builtin-diff +[umask:002] cmp stdout golden/diff-umask-002.diff +[umask:022] cmp stdout golden/diff-umask-022.diff + +# test that chezmoi diff --reverse reverses the order of arguments +exec chezmoi diff --reverse +stdout ^${WORK@R}/.*/\.file\s+${HOME@R}/\.file$ + +# test that chezmoi apply --verbose uses diff.command +exec chezmoi apply --verbose +stdout ^${HOME@R}/\.file\s+${WORK@R}/.*/\.file$ + +chhome home2/user + +# test that chezmoi diff appends the destination and target paths if diff.args does not contain any templates +exec chezmoi diff +stdout ^arg\s+${HOME@R}/\.file\s+${WORK@R}/.*/\.file$ + +# test that chezmoi apply --verbose uses diff.command +exec chezmoi apply --verbose +stdout ^arg\s+${HOME@R}/\.file\s+${WORK@R}/.*/\.file$ +exists $HOME/.file + +chhome home3/user + +# test that chezmoi diff ignores exit code 1 from diff.command if the files differ +exec chezmoi diff + +chhome home4/user + +# test that chezmoi diff does not ignore exit code 2 from diff.command +! exec chezmoi diff +stderr 'exit status 2' + +chhome home5/user + +# test that chezmoi diff does not invoke diff.command for directories if directories are excluded +exec chezmoi diff +stdout \.file +! stdout \.dir + +chhome home6/user + +# test that chezmoi diff does not invoke the diff pager when there is no diff +exec chezmoi diff +! stdout . + +# test that chezmoi diff does invoke the diff pager when there is a diff +cp golden/dot_file $CHEZMOISOURCEDIR +exec chezmoi diff +stdout diff-pager + +-- bin/diff-pager -- +#!/bin/sh + +echo diff-pager +-- golden/diff-umask-002.diff -- +diff --git a/.file b/.file +index bd729e8ee3cc005444c67dc77eed60016886b5e0..b508963510528ab709627ec448026a10a64c72ef 100664 +--- a/.file ++++ b/.file +@@ -1 +1 @@ +-# destination contents of .file ++# target contents of .file +-- golden/diff-umask-022.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 +-- golden/dot_file -- +# contents of .file +-- home/user/.config/chezmoi/chezmoi.toml -- +[diff] + command = "echo" +-- home/user/.file -- +# destination contents of .file +-- home/user/.local/share/chezmoi/dot_file -- +# target contents of .file +-- home2/user/.config/chezmoi/chezmoi.toml -- +[diff] + command = "echo" + args = ["arg"] +-- home2/user/.file -- +# destination contents of .file +-- home2/user/.local/share/chezmoi/dot_file -- +# target contents of .file +-- home3/user/.config/chezmoi/chezmoi.toml -- +[diff] + command = "false" +-- home3/user/.local/share/chezmoi/dot_file -- +# target contents of .file +-- home4/user/.config/chezmoi/chezmoi.toml -- +[diff] + command = "sh" + args = ["-c", "exit 2"] +-- home4/user/.local/share/chezmoi/dot_file -- +# target contents of .file +-- home5/user/.config/chezmoi/chezmoi.toml -- +[diff] + command = "echo" + exclude = ["dirs"] +-- home5/user/.local/share/chezmoi/dot_dir/.keep -- +-- home5/user/.local/share/chezmoi/dot_file -- +# contents of .file +-- home6/user/.config/chezmoi/chezmoi.toml -- +[diff] + pager = "diff-pager" +-- home6/user/.local/share/chezmoi/.keep --