Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add script hooks that use configured interpreters #4154

Merged
merged 1 commit into from
Dec 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion assets/chezmoi.io/docs/reference/configuration-file/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ Each event can have a `.pre` and/or a `.post` command. The *event*.`pre` command
is executed before *event* occurs and the *event*`.post` command is executed
after *event* has occurred.

A command contains a `command` and an optional array of strings `args`.
A command contains a `command` or `script` and an optional array of strings
`args`. `command`s are executed directly. `script`s are executed with
configured interpreter for the script's extension, see the [section on
interpreters](interpreters.md).

!!! example

Expand All @@ -27,6 +30,9 @@ after *event* has occurred.
[hooks.apply.post]
command = "echo"
args = ["post-apply-hook"]

[hooks.add.post]
script = "post-add-hook.ps1'
```

When running hooks, the `CHEZMOI=1` and `CHEZMOI_*` environment variables will
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Interpreters

<!-- FIXME: some of the following needs to be moved to the how-to -->

The execution of scripts and hooks on Windows depends on the file extension.
Windows will natively execute scripts with a `.bat`, `.cmd`, `.com`, and `.exe`
extensions. Other extensions require an interpreter, which must be in your
`%PATH%`.

The default script interpreters are:

| Extension | Command | Arguments |
| --------- | ------------ | --------- |
| `.nu` | `nu` | *none* |
| `.pl` | `perl` | *none* |
| `.py` | `python3` | *none* |
| `.ps1` | `powershell` | `-NoLogo` |
| `.rb` | `ruby` | *none* |

Script interpreters can be added or overridden by adding the corresponding
extension (without the leading dot) as a key under the `interpreters`
section of the configuration file.

!!! note

The leading `.` is dropped from *extension*, for example to specify the
interpreter for `.pl` files you configure `interpreters.pl` (where `.`
in this case just means "a child of" in the configuration file, however
that is specified in your preferred format).

!!! example

To change the Python interpreter to `C:\Python39\python3.exe` and add a
Tcl/Tk interpreter, include the following in your config file:

```toml title="~/.config/chezmoi/chezmoi.toml"
[interpreters.py]
command = 'C:\Python39\python3.exe'
[interpreters.tcl]
command = "tclsh"
```

Or if using YAML:

```yaml title="~/.config/chezmoi/chezmoi.yaml"
interpreters:
py:
command: "C:\Python39\python3.exe"
tcl:
command: "tclsh"
```

Note that the TOML version can also be written like this, which
resembles the YAML version more and makes it clear that the key
for each file extension should not have a leading `.`:

```toml title="~/.config/chezmoi/chezmoi.toml"
[interpreters]
py = { command = 'C:\Python39\python3.exe' }
tcl = { command = "tclsh" }
```

!!! note

If you intend to use PowerShell Core (`pwsh.exe`) as the `.ps1`
interpreter, include the following in your config file:

```toml title="~/.config/chezmoi/chezmoi.toml"
[interpreters.ps1]
command = "pwsh"
args = ["-NoLogo"]
```

If the script in the source state is a template (with a `.tmpl` extension), then
chezmoi will strip the `.tmpl` extension and use the next remaining extension to
determine the interpreter to use.
Original file line number Diff line number Diff line change
Expand Up @@ -285,10 +285,10 @@ sections:
interpreters:
'*extension*.`args`':
type: '[]string'
description: See [Scripts on Windows](../target-types.md#scripts-on-windows)
description: See [Interpreters](interpreters.md)
'*extension*.`command`':
default: '*special*'
description: See [Scripts on Windows](../target-types.md#scripts-on-windows)
description: See [Interpreters](interpreters.md)
keepassxc:
args:
type: '[]string'
Expand Down
77 changes: 1 addition & 76 deletions assets/chezmoi.io/docs/reference/target-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,82 +96,7 @@ chezmoi sets a number of `CHEZMOI*` environment variables when running scripts,
corresponding to commonly-used template data variables. Extra environment
variables can be set in the `env` or `scriptEnv` configuration variables.

### Scripts on Windows

<!-- FIXME: some of the following needs to be moved to the how-to -->

The execution of scripts on Windows depends on the script's file extension.
Windows will natively execute scripts with a `.bat`, `.cmd`, `.com`, and `.exe`
extensions. Other extensions require an interpreter, which must be in your
`%PATH%`.

The default script interpreters are:

| Extension | Command | Arguments |
| --------- | ------------ | --------- |
| `.nu` | `nu` | *none* |
| `.pl` | `perl` | *none* |
| `.py` | `python3` | *none* |
| `.ps1` | `powershell` | `-NoLogo` |
| `.rb` | `ruby` | *none* |

Script interpreters can be added or overridden by adding the corresponding
extension (without the leading dot) as a key under the `interpreters`
section of the configuration file.

!!! note

The leading `.` is dropped from *extension*, for example to specify the
interpreter for `.pl` files you configure `interpreters.pl` (where `.`
in this case just means "a child of" in the configuration file, however
that is specified in your preferred format).

!!! example

To change the Python interpreter to `C:\Python39\python3.exe` and add a
Tcl/Tk interpreter, include the following in your config file:

```toml title="~/.config/chezmoi/chezmoi.toml"
[interpreters.py]
command = 'C:\Python39\python3.exe'
[interpreters.tcl]
command = "tclsh"
```

Or if using YAML:

```yaml title="~/.config/chezmoi/chezmoi.yaml"
interpreters:
py:
command: "C:\Python39\python3.exe"
tcl:
command: "tclsh"
```

Note that the TOML version can also be written like this, which
resembles the YAML version more and makes it clear that the key
for each file extension should not have a leading `.`:

```toml title="~/.config/chezmoi/chezmoi.toml"
[interpreters]
py = { command = 'C:\Python39\python3.exe' }
tcl = { command = "tclsh" }
```

!!! note

If you intend to use PowerShell Core (`pwsh.exe`) as the `.ps1`
interpreter, include the following in your config file:

```toml title="~/.config/chezmoi/chezmoi.toml"
[interpreters.ps1]
command = "pwsh"
args = ["-NoLogo"]
```

If the script in the source state is a template (with a `.tmpl` extension), then
chezmoi will strip the `.tmpl` extension and use the next remaining extension to
determine the interpreter to use.
Scripts are executed using an interpreter, if configured. See the [section on interpreters](configuration-file/interpreters.md).

## `symlink` mode

Expand Down
1 change: 1 addition & 0 deletions assets/chezmoi.io/mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ nav:
- Variables: reference/configuration-file/variables.md
- Editor: reference/configuration-file/editor.md
- Hooks: reference/configuration-file/hooks.md
- Interpreters: reference/configuration-file/interpreters.md
- pinentry: reference/configuration-file/pinentry.md
- textconv: reference/configuration-file/textconv.md
- umask: reference/configuration-file/umask.md
Expand Down
53 changes: 39 additions & 14 deletions internal/cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"os"
"os/exec"
"os/user"
"path"
"path/filepath"
"reflect"
"regexp"
Expand Down Expand Up @@ -79,6 +80,7 @@ type doPurgeOptions struct {

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"`
}

Expand Down Expand Up @@ -1213,7 +1215,7 @@ func (c *Config) destAbsPathInfos(
// diffFile outputs the diff between fromData and fromMode and toData and toMode
// at path.
func (c *Config) diffFile(
path chezmoi.RelPath,
relPath chezmoi.RelPath,
fromData []byte,
fromMode fs.FileMode,
toData []byte,
Expand All @@ -1227,19 +1229,19 @@ func (c *Config) diffFile(
}
if fromMode.IsRegular() {
var err error
fromData, _, err = c.TextConv.convert(path.String(), fromData)
fromData, _, err = c.TextConv.convert(relPath.String(), fromData)
if err != nil {
return err
}
}
if toMode.IsRegular() {
var err error
toData, _, err = c.TextConv.convert(path.String(), toData)
toData, _, err = c.TextConv.convert(relPath.String(), toData)
if err != nil {
return err
}
}
diffPatch, err := chezmoi.DiffPatch(path, fromData, fromMode, toData, toMode)
diffPatch, err := chezmoi.DiffPatch(relPath, fromData, fromMode, toData, toMode)
if err != nil {
return err
}
Expand Down Expand Up @@ -2515,22 +2517,45 @@ func (c *Config) runEditor(args []string) error {
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 {
command := c.Hooks[hook].Post
if command.Command == "" {
return nil
if err := c.runHook(c.Hooks[hook].Post); err != nil {
return fmt.Errorf("%s: post: %w", hook, err)
}
return c.run(c.homeDirAbsPath, command.Command, command.Args)
return nil
}

// runHookPre runs the hook's pre command, if it is set.
func (c *Config) runHookPre(hook string) error {
command := c.Hooks[hook].Pre
if command.Command == "" {
return nil
if err := c.runHook(c.Hooks[hook].Pre); err != nil {
return fmt.Errorf("%s: pre: %w", hook, err)
}
return c.run(c.homeDirAbsPath, command.Command, command.Args)
return nil
}

// setEncryption configures c's encryption.
Expand Down Expand Up @@ -2991,8 +3016,8 @@ func (f *ConfigFile) toMap() map[string]any {

func parseCommand(command string, args []string) (string, []string, error) {
// If command is found, then return it.
if path, err := chezmoi.LookPath(command); err == nil {
return path, args, nil
if commandPath, err := chezmoi.LookPath(command); err == nil {
return commandPath, args, nil
}

// Otherwise, if the command contains spaces, parse it as a shell command.
Expand Down
14 changes: 14 additions & 0 deletions internal/cmd/testdata/scripts/hooks_windows.txtar
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[!windows] skip 'Windows only'

# test that chezmoi status runs hooks with an interpreter
exec chezmoi status
stdout pre-status-hook

-- bin/pre-status-hook.ps1 --
"pre-status-hook"
-- home/user/.config/chezmoi/chezmoi.yaml --
hooks:
status:
pre:
script: 'pre-status-hook.ps1'
-- home/user/.local/share/chezmoi/.keep --
Loading