diff --git a/assets/chezmoi.io/docs/reference/templates/functions/isExecutable.md b/assets/chezmoi.io/docs/reference/templates/functions/isExecutable.md new file mode 100644 index 00000000000..06336593ef2 --- /dev/null +++ b/assets/chezmoi.io/docs/reference/templates/functions/isExecutable.md @@ -0,0 +1,11 @@ +# `isExecutable` *file* + +`isExecutable` returns true if a file is executable. + +!!! example + + ``` + {{ if isExecutable "/bin/echo" }} + `sh` is executable + {{ end }} + ``` diff --git a/assets/chezmoi.io/mkdocs.yml b/assets/chezmoi.io/mkdocs.yml index f3415a88a47..5d9bd59d49a 100644 --- a/assets/chezmoi.io/mkdocs.yml +++ b/assets/chezmoi.io/mkdocs.yml @@ -195,6 +195,7 @@ nav: - include: reference/templates/functions/include.md - includeTemplate: reference/templates/functions/includeTemplate.md - ioreg: reference/templates/functions/ioreg.md + - isExecutable: reference/templates/functions/isExecutable.md - joinPath: reference/templates/functions/joinPath.md - jq: reference/templates/functions/jq.md - lookPath: reference/templates/functions/lookPath.md diff --git a/internal/chezmoi/chezmoi_unix.go b/internal/chezmoi/chezmoi_unix.go index dd8a57d2683..2aa7df61bd6 100644 --- a/internal/chezmoi/chezmoi_unix.go +++ b/internal/chezmoi/chezmoi_unix.go @@ -15,8 +15,8 @@ func init() { unix.Umask(int(Umask)) } -// isExecutable returns if fileInfo is executable. -func isExecutable(fileInfo fs.FileInfo) bool { +// IsExecutable returns if fileInfo is executable. +func IsExecutable(fileInfo fs.FileInfo) bool { return fileInfo.Mode().Perm()&0o111 != 0 } diff --git a/internal/chezmoi/chezmoi_windows.go b/internal/chezmoi/chezmoi_windows.go index a807449fd41..6d0cd141e09 100644 --- a/internal/chezmoi/chezmoi_windows.go +++ b/internal/chezmoi/chezmoi_windows.go @@ -2,13 +2,31 @@ package chezmoi import ( "io/fs" + "os" + "path/filepath" + "strings" + + "golang.org/x/exp/slices" ) const nativeLineEnding = "\r\n" -// isExecutable returns false on Windows. -func isExecutable(fileInfo fs.FileInfo) bool { - return false +var pathExts = strings.Split(os.Getenv("PATHEXT"), string(filepath.ListSeparator)) + +// IsExecutable checks if the file is a regular file and has an extension listed +// in the PATHEXT environment variable as per +// https://www.nextofwindows.com/what-is-pathext-environment-variable-in-windows. +func IsExecutable(fileInfo fs.FileInfo) bool { + if !fileInfo.Mode().IsRegular() { + return false + } + ext := filepath.Ext(fileInfo.Name()) + if ext == "" { + return false + } + return slices.ContainsFunc(pathExts, func(pathExt string) bool { + return strings.EqualFold(pathExt, ext) + }) } // isPrivate returns false on Windows. diff --git a/internal/chezmoi/sourcestate.go b/internal/chezmoi/sourcestate.go index d2eada8c76d..7dd3a01b089 100644 --- a/internal/chezmoi/sourcestate.go +++ b/internal/chezmoi/sourcestate.go @@ -2092,7 +2092,7 @@ func (s *SourceState) newSourceStateFileEntryFromFile( fileAttr := FileAttr{ TargetName: fileInfo.Name(), Encrypted: options.Encrypt, - Executable: isExecutable(fileInfo), + Executable: IsExecutable(fileInfo), Private: isPrivate(fileInfo), ReadOnly: isReadOnly(fileInfo), Template: options.Template, @@ -2316,7 +2316,7 @@ func (s *SourceState) readExternalArchive( TargetName: fileInfo.Name(), Type: SourceFileTypeFile, Empty: fileInfo.Size() == 0, - Executable: isExecutable(fileInfo), + Executable: IsExecutable(fileInfo), Private: isPrivate(fileInfo), ReadOnly: isReadOnly(fileInfo), } @@ -2448,7 +2448,7 @@ func (s *SourceState) readExternalArchiveFile( TargetName: fileInfo.Name(), Type: SourceFileTypeFile, Empty: fileInfo.Size() == 0, - Executable: isExecutable(fileInfo) || external.Executable, + Executable: IsExecutable(fileInfo) || external.Executable, Private: isPrivate(fileInfo), ReadOnly: isReadOnly(fileInfo), } @@ -2530,7 +2530,7 @@ func (s *SourceState) readExternalDir( TargetName: fileInfo.Name(), Type: SourceFileTypeFile, Empty: true, - Executable: isExecutable(fileInfo), + Executable: IsExecutable(fileInfo), Private: isPrivate(fileInfo), ReadOnly: isReadOnly(fileInfo), } diff --git a/internal/cmd/config.go b/internal/cmd/config.go index 7fa56d818af..782d364449f 100644 --- a/internal/cmd/config.go +++ b/internal/cmd/config.go @@ -420,6 +420,7 @@ func newConfig(options ...configOption) (*Config, error) { "include": c.includeTemplateFunc, "includeTemplate": c.includeTemplateTemplateFunc, "ioreg": c.ioregTemplateFunc, + "isExecutable": c.isExecutableTemplateFunc, "joinPath": c.joinPathTemplateFunc, "jq": c.jqTemplateFunc, "keepassxc": c.keepassxcTemplateFunc, diff --git a/internal/cmd/templatefuncs.go b/internal/cmd/templatefuncs.go index 5231386a0d0..2bb3bba3b98 100644 --- a/internal/cmd/templatefuncs.go +++ b/internal/cmd/templatefuncs.go @@ -315,6 +315,18 @@ func (c *Config) lookPathTemplateFunc(file string) string { } } +func (c *Config) isExecutableTemplateFunc(file string) bool { + fileInfo, err := c.fileSystem.Stat(file) + switch { + case err == nil: + return chezmoi.IsExecutable(fileInfo) + case errors.Is(err, fs.ErrNotExist): + return false + default: + panic(err) + } +} + func (c *Config) lstatTemplateFunc(name string) any { switch fileInfo, err := c.fileSystem.Lstat(name); { case err == nil: diff --git a/internal/cmd/testdata/scripts/templatefuncs.txtar b/internal/cmd/testdata/scripts/templatefuncs.txtar index 0a95f6e4867..f41c74ceb2e 100644 --- a/internal/cmd/testdata/scripts/templatefuncs.txtar +++ b/internal/cmd/testdata/scripts/templatefuncs.txtar @@ -68,6 +68,14 @@ stdout a${/}b exec chezmoi execute-template '{{ dict "key" "value" | jq ".key" | first }}' stdout ^value$ +# test isExecutable template function positive test case +[!windows] exec chezmoi execute-template '{{ if isExecutable "/bin/echo" }}executable{{end}}' +[!windows] stdout ^executable$ + +# test isExecutable template function negative test case +[!windows] exec chezmoi execute-template '{{ if isExecutable "/etc/fstat" }}executable{{end}}' +[!windows] stdout ^$ + # test lookPath template function to find in PATH exec chezmoi execute-template '{{ lookPath "go" }}' stdout go$exe