From e86f817bab80f36f8913dd161e5137eb636b0f4f Mon Sep 17 00:00:00 2001 From: Arran Ubels Date: Sat, 5 Aug 2023 17:32:28 +1000 Subject: [PATCH 1/3] feature: Exposes `isExecutable` to the user --- .../reference/templates/functions/isExecutable.md | 11 +++++++++++ assets/chezmoi.io/mkdocs.yml | 1 + internal/chezmoi/chezmoi_unix.go | 4 ++-- internal/chezmoi/chezmoi_windows.go | 2 +- internal/chezmoi/sourcestate.go | 8 ++++---- internal/cmd/config.go | 1 + internal/cmd/templatefuncs.go | 12 ++++++++++++ internal/cmd/testdata/scripts/templatefuncs.txtar | 8 ++++++++ 8 files changed, 40 insertions(+), 7 deletions(-) create mode 100644 assets/chezmoi.io/docs/reference/templates/functions/isExecutable.md 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..e630057285c 100644 --- a/internal/chezmoi/chezmoi_windows.go +++ b/internal/chezmoi/chezmoi_windows.go @@ -7,7 +7,7 @@ import ( const nativeLineEnding = "\r\n" // isExecutable returns false on Windows. -func isExecutable(fileInfo fs.FileInfo) bool { +func IsExecutable(fileInfo fs.FileInfo) bool { return false } 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 From 8c9905ca89939ab30e698522f366fa2ee7634489 Mon Sep 17 00:00:00 2001 From: Arran Ubels Date: Sat, 5 Aug 2023 16:43:35 +1000 Subject: [PATCH 2/3] fix: Provide some logic for `isExecutable` under windows for determining if it's an executable BREAKING CHANGE: `isExecutable` no longer always returns false and tries to determine execution status by file extension --- internal/chezmoi/chezmoi_windows.go | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/internal/chezmoi/chezmoi_windows.go b/internal/chezmoi/chezmoi_windows.go index a807449fd41..985305e6682 100644 --- a/internal/chezmoi/chezmoi_windows.go +++ b/internal/chezmoi/chezmoi_windows.go @@ -2,13 +2,37 @@ package chezmoi import ( "io/fs" + "os" + "path/filepath" + "strings" ) const nativeLineEnding = "\r\n" -// isExecutable returns false on Windows. +var pathExt []string = nil + +// getPathExt a singleton that obtains the PathExt environment variable and splits it up using the OS `ListSeparator` +func getPathExt() []string { + if pathExt == nil { + pathExt = strings.Split(os.Getenv("PathExt"), string(filepath.ListSeparator)) + } + return pathExt +} + +// isExecutable checks if the file has an extension listed in the `PathExt` variable as per: +// https://www.nextofwindows.com/what-is-pathext-environment-variable-in-windows then checks to see if it's regular file func isExecutable(fileInfo fs.FileInfo) bool { - return false + foundPathExt := false + cmdExt := filepath.Ext(fileInfo.Name()) + if cmdExt != "" { + for _, ext := range getPathExt() { + if strings.EqualFold(cmdExt, ext) { + foundPathExt = true + break + } + } + } + return foundPathExt && fileInfo.Mode().IsRegular() } // isPrivate returns false on Windows. From b1434c8901eaa8c01a29e4ea0734023793a160bd Mon Sep 17 00:00:00 2001 From: Tom Payne Date: Sun, 6 Aug 2023 12:12:47 +0200 Subject: [PATCH 3/3] chore: Tidy up implementation if isExecutable on Windows --- internal/chezmoi/chezmoi_windows.go | 36 ++++++++++++----------------- 1 file changed, 15 insertions(+), 21 deletions(-) diff --git a/internal/chezmoi/chezmoi_windows.go b/internal/chezmoi/chezmoi_windows.go index 985305e6682..741fb0af623 100644 --- a/internal/chezmoi/chezmoi_windows.go +++ b/internal/chezmoi/chezmoi_windows.go @@ -5,34 +5,28 @@ import ( "os" "path/filepath" "strings" + + "golang.org/x/exp/slices" ) const nativeLineEnding = "\r\n" -var pathExt []string = nil +var pathExts = strings.Split(os.Getenv("PATHEXT"), string(filepath.ListSeparator)) -// getPathExt a singleton that obtains the PathExt environment variable and splits it up using the OS `ListSeparator` -func getPathExt() []string { - if pathExt == nil { - pathExt = strings.Split(os.Getenv("PathExt"), string(filepath.ListSeparator)) - } - return pathExt -} - -// isExecutable checks if the file has an extension listed in the `PathExt` variable as per: -// https://www.nextofwindows.com/what-is-pathext-environment-variable-in-windows then checks to see if it's regular file +// 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 { - foundPathExt := false - cmdExt := filepath.Ext(fileInfo.Name()) - if cmdExt != "" { - for _, ext := range getPathExt() { - if strings.EqualFold(cmdExt, ext) { - foundPathExt = true - break - } - } + if !fileInfo.Mode().IsRegular() { + return false + } + ext := filepath.Ext(fileInfo.Name()) + if ext == "" { + return false } - return foundPathExt && fileInfo.Mode().IsRegular() + return slices.ContainsFunc(pathExts, func(pathExt string) bool { + return strings.EqualFold(pathExt, ext) + }) } // isPrivate returns false on Windows.