Skip to content

Commit

Permalink
feat: Add script hooks that use configured interpreters
Browse files Browse the repository at this point in the history
  • Loading branch information
twpayne committed Dec 23, 2024
1 parent 50f9884 commit ec276de
Show file tree
Hide file tree
Showing 7 changed files with 140 additions and 93 deletions.
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 --

0 comments on commit ec276de

Please sign in to comment.