Add backup hooks

Amolith created

Support backup-specific _pre_hooks and _post_hooks config keys with normal most-specific override semantics. Pre-hooks run before restic backup and can stop the backup; post-hooks run after restic is attempted and receive restic outcome environment variables.

Supervise restic as a child process so post-hooks can run while preserving restic exit codes, workdir handling, and interactive terminal behavior. Document hook usage and status mapping in the examples.

Change summary

AGENTS.md                         |  31 -
cmd/backup_hooks_feature_test.go  | 527 +++++++++++++++++++++++++++++++++
cmd/root.go                       |   5 
examples/README.md                |  33 ++
examples/keld/config.toml         |   4 
examples/keld/config_long.toml    |  17 +
features/backup_hooks.feature     |  96 ++++++
go.mod                            |  10 
go.sum                            |  22 +
internal/config/config.go         |  18 +
internal/restic/exec.go           | 153 ++++++++
internal/restic/exec_test.go      |   2 
internal/restic/list_snapshots.go |   2 
internal/restic/lsnodes.go        |   2 
internal/restic/signal_other.go   |  33 ++
internal/restic/signal_unix.go    |  43 ++
16 files changed, 968 insertions(+), 30 deletions(-)

Detailed changes

AGENTS.md πŸ”—

@@ -1,6 +1,4 @@
-# AGENTS.md - Working with Keld
-
-## Project Overview
+# AGENTS.md
 
 **Keld** is a friendly TOML-configured wrapper around
 [restic](https://restic.net/) (a backup tool). It provides:
@@ -56,13 +54,15 @@ CLI overrides
 
 ### Special Config Keys
 
-| Key              | Purpose                                                            |
-| ---------------- | ------------------------------------------------------------------ |
-| `_arguments`     | Positional args passed to restic (array or space-separated string) |
-| `_workdir`       | Directory to chdir before exec                                     |
-| `_command`       | Restic subcommand (allows aliasing)                                |
-| `*.environ`      | Section suffix for environment variables                           |
-| `FOO_COMMAND`    | In `.environ`: executed via `sh -c`, stdout sets `FOO`             |
+| Key           | Purpose                                                            |
+| ------------- | ------------------------------------------------------------------ |
+| `_arguments`  | Positional args passed to restic (array or space-separated string) |
+| `_workdir`    | Directory to chdir before exec                                     |
+| `_command`    | Restic subcommand (allows aliasing)                                |
+| `_pre_hooks`  | Backup-only shell commands run before restic backup                |
+| `_post_hooks` | Backup-only shell commands run after restic backup is attempted    |
+| `*.environ`   | Section suffix for environment variables                           |
+| `FOO_COMMAND` | In `.environ`: executed via `sh -c`, stdout sets `FOO`             |
 
 ### Interpolation
 
@@ -93,14 +93,11 @@ See `examples/keld/config.toml` for comprehensive examples.
 - Nested tables (sub-commands) are **not** merged across sectionsβ€”only leaf keys
 - Multi-line strings become repeated flags (split on `\n`)
 
-### syscall.Exec
-
-`restic.Run()` uses `syscall.Exec` which **replaces the current process**.
-This means:
+### Restic Process Supervision
 
-- No Go code runs after successful exec
-- No deferred functions execute
-- Dry-run mode exists specifically to show what would run
+`restic.Run()` supervises restic as a child process. This allows Keld to run
+post-backup hooks after restic exits and preserve restic's numeric exit code for
+callers.
 
 ### Test Isolation
 

cmd/backup_hooks_feature_test.go πŸ”—

@@ -0,0 +1,527 @@
+package cmd
+
+import (
+	"bytes"
+	"errors"
+	"io"
+	"os"
+	"path/filepath"
+	"reflect"
+	"strconv"
+	"strings"
+	"sync"
+	"testing"
+
+	"github.com/regen-network/gocuke"
+	"github.com/spf13/cobra"
+
+	"git.secluded.site/keld/internal/config"
+)
+
+func TestBackupHooksFeature(t *testing.T) {
+	gocuke.NewRunner(t, &backupHooksSuite{}).
+		Path("../features/backup_hooks.feature").
+		NonParallel().
+		Run()
+}
+
+type backupHooksSuite struct {
+	gocuke.TestingT
+
+	tmpDir       string
+	configPath   string
+	logPath      string
+	preset       string
+	resticCode   int
+	resolved     *config.ResolvedConfig
+	runErr       error
+	runStdout    string
+	runStderr    string
+	oldPreset    string
+	oldShowCmd   bool
+	oldConfig    string
+	oldDryRun    string
+	oldConfigEnv string
+	oldExec      string
+	oldTestLog   string
+	oldTestExit  string
+}
+
+func (s *backupHooksSuite) Before() {
+	var err error
+	s.tmpDir, err = os.MkdirTemp("", "keld-backup-hooks-*")
+	if err != nil {
+		s.Fatalf("creating temp dir: %v", err)
+	}
+	s.configPath = filepath.Join(s.tmpDir, "config.toml")
+	s.logPath = filepath.Join(s.tmpDir, "events.log")
+	s.preset = "home"
+	s.resticCode = 0
+
+	s.oldPreset, s.oldShowCmd, s.oldConfig = flagPreset, flagShowCmd, flagConfigFile
+	s.oldDryRun = os.Getenv("KELD_DRYRUN")
+	s.oldConfigEnv = os.Getenv("KELD_CONFIG_FILE")
+	s.oldExec = os.Getenv("KELD_EXECUTABLE")
+	s.oldTestLog = os.Getenv("KELD_TEST_LOG")
+	s.oldTestExit = os.Getenv("KELD_TEST_RESTIC_EXIT")
+
+	fakeRestic := filepath.Join(s.tmpDir, "restic")
+	script := `#!/bin/sh
+printf 'restic %s\n' "$*" >> "$KELD_TEST_LOG"
+exit "${KELD_TEST_RESTIC_EXIT:-0}"
+`
+	if err := os.WriteFile(fakeRestic, []byte(script), 0o755); err != nil {
+		s.Fatalf("writing fake restic: %v", err)
+	}
+	mustSetenv(s, "KELD_EXECUTABLE", fakeRestic)
+	mustSetenv(s, "KELD_TEST_LOG", s.logPath)
+	mustSetenv(s, "KELD_TEST_RESTIC_EXIT", "0")
+}
+
+func (s *backupHooksSuite) After() {
+	flagPreset, flagShowCmd, flagConfigFile = s.oldPreset, s.oldShowCmd, s.oldConfig
+	restoreEnv("KELD_DRYRUN", s.oldDryRun)
+	restoreEnv("KELD_CONFIG_FILE", s.oldConfigEnv)
+	restoreEnv("KELD_EXECUTABLE", s.oldExec)
+	restoreEnv("KELD_TEST_LOG", s.oldTestLog)
+	restoreEnv("KELD_TEST_RESTIC_EXIT", s.oldTestExit)
+	_ = os.RemoveAll(s.tmpDir)
+}
+
+func (s *backupHooksSuite) TheSelectedPresetSuppliesBackupPaths() {
+	s.writeConfig("home", nil, nil)
+}
+
+func (s *backupHooksSuite) BackupHooksAreConfiguredInMultipleMatchingSections() {
+	s.preset = "home@cloud"
+	s.writeRawConfig(`
+[global.backup]
+_arguments = [` + quote(filepath.Join(s.tmpDir, "source")) + `]
+_pre_hooks = [` + quote(s.logHook("pre global")) + `]
+_post_hooks = [` + quote(s.logHook("post global")) + `]
+
+["@cloud".backup]
+_pre_hooks = [` + quote(s.logHook("pre suffix")) + `]
+_post_hooks = [` + quote(s.logHook("post suffix")) + `]
+
+["home@".backup]
+_pre_hooks = [` + quote(s.logHook("pre prefix")) + `]
+_post_hooks = [` + quote(s.logHook("post prefix")) + `]
+
+["home@cloud".backup]
+_pre_hooks = [` + quote(s.logHook("pre full")) + `]
+_post_hooks = [` + quote(s.logHook("post full")) + `]
+`)
+}
+
+func (s *backupHooksSuite) TheBackupConfigurationIsResolved() {
+	cfg, err := config.Resolve(s.preset, "backup", nil)
+	if err != nil {
+		s.Fatalf("resolving config: %v", err)
+	}
+	s.resolved = cfg
+}
+
+func (s *backupHooksSuite) ThePrehooksAreOrderedFromLowestToHighestPrioritySection() {
+	want := []string{s.logHook("pre global"), s.logHook("pre suffix"), s.logHook("pre prefix"), s.logHook("pre full")}
+	if !reflect.DeepEqual(s.resolved.PreHooks, want) {
+		s.Fatalf("pre hook order mismatch:\n got: %#v\nwant: %#v", s.resolved.PreHooks, want)
+	}
+}
+
+func (s *backupHooksSuite) ThePrehooksComeFromTheMostspecificSectionThatDefinesPrehooks() {
+	want := []string{s.logHook("pre full")}
+	if !reflect.DeepEqual(s.resolved.PreHooks, want) {
+		s.Fatalf("pre hook override mismatch:\n got: %#v\nwant: %#v", s.resolved.PreHooks, want)
+	}
+}
+
+func (s *backupHooksSuite) ThePosthooksAreOrderedFromLowestToHighestPrioritySection() {
+	want := []string{s.logHook("post global"), s.logHook("post suffix"), s.logHook("post prefix"), s.logHook("post full")}
+	if !reflect.DeepEqual(s.resolved.PostHooks, want) {
+		s.Fatalf("post hook order mismatch:\n got: %#v\nwant: %#v", s.resolved.PostHooks, want)
+	}
+}
+
+func (s *backupHooksSuite) ThePosthooksComeFromTheMostspecificSectionThatDefinesPosthooks() {
+	want := []string{s.logHook("post full")}
+	if !reflect.DeepEqual(s.resolved.PostHooks, want) {
+		s.Fatalf("post hook override mismatch:\n got: %#v\nwant: %#v", s.resolved.PostHooks, want)
+	}
+}
+
+func (s *backupHooksSuite) TheHooksAreNotPassedToResticAsFlags() {
+	for _, f := range s.resolved.Flags {
+		if strings.Contains(f.Name, "hook") {
+			s.Fatalf("hook leaked into restic flags: %#v", f)
+		}
+	}
+}
+
+func (s *backupHooksSuite) BackupHooksAreConfiguredInALessspecificMatchingSection() {
+	s.preset = "home"
+	s.writeRawConfig(`
+[global.backup]
+_arguments = [` + quote(filepath.Join(s.tmpDir, "source")) + `]
+_pre_hooks = [` + quote(s.logHook("pre inherited")) + `]
+_post_hooks = [` + quote(s.logHook("post inherited")) + `]
+
+[home.backup]
+tag = "specific"
+`)
+}
+
+func (s *backupHooksSuite) TheMostspecificMatchingSectionOmitsHookKeys() {}
+
+func (s *backupHooksSuite) TheInheritedPrehooksAreUsed() {
+	want := []string{s.logHook("pre inherited")}
+	if !reflect.DeepEqual(s.resolved.PreHooks, want) {
+		s.Fatalf("inherited pre hooks mismatch:\n got: %#v\nwant: %#v", s.resolved.PreHooks, want)
+	}
+}
+
+func (s *backupHooksSuite) TheInheritedPosthooksAreUsed() {
+	want := []string{s.logHook("post inherited")}
+	if !reflect.DeepEqual(s.resolved.PostHooks, want) {
+		s.Fatalf("inherited post hooks mismatch:\n got: %#v\nwant: %#v", s.resolved.PostHooks, want)
+	}
+}
+
+func (s *backupHooksSuite) TheMostspecificMatchingSectionClearsHookKeys() {
+	s.writeRawConfig(`
+[global.backup]
+_arguments = [` + quote(filepath.Join(s.tmpDir, "source")) + `]
+_pre_hooks = [` + quote(s.logHook("pre inherited")) + `]
+_post_hooks = [` + quote(s.logHook("post inherited")) + `]
+
+[home.backup]
+_pre_hooks = []
+_post_hooks = []
+`)
+}
+
+func (s *backupHooksSuite) NoPrehooksAreConfigured() {
+	if len(s.resolved.PreHooks) != 0 {
+		s.Fatalf("expected no pre-hooks, got %#v", s.resolved.PreHooks)
+	}
+}
+
+func (s *backupHooksSuite) NoPosthooksAreConfigured() {
+	if len(s.resolved.PostHooks) != 0 {
+		s.Fatalf("expected no post-hooks, got %#v", s.resolved.PostHooks)
+	}
+}
+
+func (s *backupHooksSuite) TheBackupConfigurationIncludesPrehooks() {
+	s.writeConfig("home", []string{s.logHook("pre one"), s.logHook("pre two")}, nil)
+}
+
+func (s *backupHooksSuite) KeldRunsTheBackup() {
+	flagPreset = s.preset
+	flagShowCmd = false
+	flagConfigFile = s.configPath
+	mustSetenv(s, "KELD_TEST_RESTIC_EXIT", strconv.Itoa(s.resticCode))
+
+	var err error
+	s.runStdout, s.runStderr, err = captureOutput(s, func() error {
+		return runCommand("backup", lookupSubcommandForHooks(s, "backup"), nil, nil, nil)
+	})
+	s.runErr = err
+}
+
+func (s *backupHooksSuite) EachPrehookRunsBeforeResticBackup() {
+	lines := s.logLines()
+	want := []string{"pre one", "pre two", "restic backup"}
+	for i, prefix := range want {
+		if len(lines) <= i || !strings.HasPrefix(lines[i], prefix) {
+			s.Fatalf("event %d mismatch: got log %#v, want prefix %q", i, lines, prefix)
+		}
+	}
+}
+
+func (s *backupHooksSuite) ResticBackupIsAttempted() {
+	if !s.logContains("restic backup") {
+		s.Fatalf("restic backup was not attempted; log: %#v", s.logLines())
+	}
+}
+
+func (s *backupHooksSuite) TheBackupConfigurationIncludesAFailingPrehook() {
+	s.writeConfig("home", []string{s.logHook("pre fail") + "; exit 42"}, []string{s.logHook("post should-not-run")})
+}
+
+func (s *backupHooksSuite) ResticBackupIsNotAttempted() {
+	if s.logContains("restic backup") {
+		s.Fatalf("restic backup was attempted; log: %#v", s.logLines())
+	}
+}
+
+func (s *backupHooksSuite) PosthooksAreNotRun() {
+	if s.logContains("post") {
+		s.Fatalf("post-hook ran unexpectedly; log: %#v", s.logLines())
+	}
+}
+
+func (s *backupHooksSuite) KeldReportsThePrehookFailure() {
+	if s.runErr == nil || !strings.Contains(s.runErr.Error(), "pre-hook") {
+		s.Fatalf("expected pre-hook failure, got %v", s.runErr)
+	}
+}
+
+func (s *backupHooksSuite) TheBackupConfigurationIncludesPosthooks() {
+	s.writeConfig("home", nil, []string{s.postEnvHook("post")})
+}
+
+func (s *backupHooksSuite) ResticBackupExitsWithCode(a int64) {
+	s.resticCode = int(a)
+}
+
+func (s *backupHooksSuite) EachPosthookRunsAfterResticBackup() {
+	lines := s.logLines()
+	if len(lines) < 2 || !strings.HasPrefix(lines[0], "restic backup") || !strings.HasPrefix(lines[1], "post") {
+		s.Fatalf("post-hook did not run after restic; log: %#v", lines)
+	}
+}
+
+func (s *backupHooksSuite) EachPosthookReceivesKeldresticexitcodeSetTo(a string) {
+	if !s.logContains("code=" + a) {
+		s.Fatalf("post-hook did not receive exit code %q; log: %#v", a, s.logLines())
+	}
+}
+
+func (s *backupHooksSuite) EachPosthookReceivesKeldresticstatusSetTo(a string) {
+	if !s.logContains("status=" + a) {
+		s.Fatalf("post-hook did not receive status %q; log: %#v", a, s.logLines())
+	}
+}
+
+func (s *backupHooksSuite) KeldExitsWithCode(a int64) {
+	code := int(a)
+	if code == 0 {
+		if s.runErr != nil {
+			s.Fatalf("expected successful exit, got %v", s.runErr)
+		}
+		return
+	}
+
+	var exitErr interface{ ExitCode() int }
+	if !errors.As(s.runErr, &exitErr) {
+		s.Fatalf("expected exit error with code %d, got %T: %v", code, s.runErr, s.runErr)
+	}
+	if got := exitErr.ExitCode(); got != code {
+		s.Fatalf("exit code mismatch: got %d, want %d", got, code)
+	}
+}
+
+func (s *backupHooksSuite) TheBackupConfigurationIncludesAFailingPosthook() {
+	s.writeConfig("home", nil, []string{s.logHook("post fail") + "; exit 44"})
+}
+
+func (s *backupHooksSuite) KeldReportsThePosthookFailure() {
+	if !strings.Contains(s.runStderr, "post-hook") {
+		s.Fatalf("expected post-hook failure on stderr, got stdout=%q stderr=%q err=%v", s.runStdout, s.runStderr, s.runErr)
+	}
+}
+
+func (s *backupHooksSuite) TheBackupConfigurationIncludesPrehooksAndPosthooks() {
+	s.writeConfig("home", []string{s.logHook("pre preview")}, []string{s.logHook("post preview")})
+}
+
+func (s *backupHooksSuite) ResticBackupFailsToStart() {
+	s.writeConfig("home", []string{s.logHook("pre preview")}, []string{s.postEnvHook("post preview")})
+	fakeRestic := os.Getenv("KELD_EXECUTABLE")
+	if err := os.WriteFile(fakeRestic, []byte("#!/definitely/missing/keld-restic-interpreter\n"), 0o755); err != nil {
+		s.Fatalf("rewriting fake restic: %v", err)
+	}
+}
+
+func (s *backupHooksSuite) EachPosthookRunsAfterTheFailedResticStart() {
+	lines := s.logLines()
+	if len(lines) < 2 || !strings.HasPrefix(lines[0], "pre preview") || !strings.HasPrefix(lines[1], "post preview") {
+		s.Fatalf("post-hook did not run after failed restic start; log: %#v", lines)
+	}
+}
+
+func (s *backupHooksSuite) EachPosthookReceivesNoKeldresticexitcode() {
+	for _, line := range s.logLines() {
+		if strings.HasPrefix(line, "post") && strings.Contains(line, "code=") && !strings.Contains(line, "code= ") {
+			s.Fatalf("post-hook unexpectedly received exit code; log: %#v", s.logLines())
+		}
+	}
+}
+
+func (s *backupHooksSuite) KeldReportsTheResticStartFailure() {
+	if s.runErr == nil || !strings.Contains(s.runErr.Error(), "starting restic") {
+		s.Fatalf("expected restic start failure, got %v", s.runErr)
+	}
+}
+
+func (s *backupHooksSuite) KeldShowsTheResolvedBackupCommand() {
+	flagPreset = s.preset
+	flagShowCmd = true
+	flagConfigFile = s.configPath
+
+	var err error
+	s.runStdout, s.runStderr, err = captureOutput(s, func() error {
+		return runCommand("backup", lookupSubcommandForHooks(s, "backup"), nil, nil, nil)
+	})
+	s.runErr = err
+}
+
+func (s *backupHooksSuite) TheOutputIncludesTheConfiguredPrehooks() {
+	if !strings.Contains(s.runStdout, "pre-hooks:") || !strings.Contains(s.runStdout, "pre preview") {
+		s.Fatalf("preview missing pre-hooks: %s", s.runStdout)
+	}
+}
+
+func (s *backupHooksSuite) TheOutputIncludesTheConfiguredPosthooks() {
+	if !strings.Contains(s.runStdout, "post-hooks:") || !strings.Contains(s.runStdout, "post preview") {
+		s.Fatalf("preview missing post-hooks: %s", s.runStdout)
+	}
+}
+
+func (s *backupHooksSuite) TheOutputDoesNotIncludeKeldresticexitcode() {
+	if strings.Contains(s.runStdout, "KELD_RESTIC_EXIT_CODE") {
+		s.Fatalf("preview included runtime exit code env: %s", s.runStdout)
+	}
+}
+
+func (s *backupHooksSuite) TheOutputDoesNotIncludeKeldresticstatus() {
+	if strings.Contains(s.runStdout, "KELD_RESTIC_STATUS") {
+		s.Fatalf("preview included runtime status env: %s", s.runStdout)
+	}
+}
+
+func (s *backupHooksSuite) writeConfig(preset string, preHooks, postHooks []string) {
+	s.preset = preset
+	var b strings.Builder
+	b.WriteString("[" + quote(preset) + "]\n")
+	b.WriteString("tag = \"bdd\"\n\n")
+	b.WriteString("[" + quote(preset) + ".backup]\n")
+	b.WriteString("_arguments = [" + quote(filepath.Join(s.tmpDir, "source")) + "]\n")
+	if len(preHooks) > 0 {
+		b.WriteString("_pre_hooks = [" + quotedList(preHooks) + "]\n")
+	}
+	if len(postHooks) > 0 {
+		b.WriteString("_post_hooks = [" + quotedList(postHooks) + "]\n")
+	}
+	s.writeRawConfig(b.String())
+}
+
+func (s *backupHooksSuite) writeRawConfig(toml string) {
+	if err := os.WriteFile(s.configPath, []byte(toml), 0o600); err != nil {
+		s.Fatalf("writing config: %v", err)
+	}
+	flagConfigFile = s.configPath
+	mustSetenv(s, "KELD_CONFIG_FILE", s.configPath)
+}
+
+func (s *backupHooksSuite) logHook(message string) string {
+	return "printf " + quote(message+"\n") + " >> \"$KELD_TEST_LOG\""
+}
+
+func (s *backupHooksSuite) postEnvHook(prefix string) string {
+	return "printf " + quote(prefix+" code=%s status=%s\n") + " \"$KELD_RESTIC_EXIT_CODE\" \"$KELD_RESTIC_STATUS\" >> \"$KELD_TEST_LOG\""
+}
+
+func (s *backupHooksSuite) logLines() []string {
+	data, err := os.ReadFile(s.logPath)
+	if errors.Is(err, os.ErrNotExist) {
+		return nil
+	}
+	if err != nil {
+		s.Fatalf("reading log: %v", err)
+	}
+	return strings.Split(strings.TrimRight(string(data), "\n"), "\n")
+}
+
+func (s *backupHooksSuite) logContains(needle string) bool {
+	for _, line := range s.logLines() {
+		if strings.Contains(line, needle) {
+			return true
+		}
+	}
+	return false
+}
+
+func quote(s string) string { return strconv.Quote(s) }
+
+func quotedList(values []string) string {
+	quoted := make([]string, len(values))
+	for i, value := range values {
+		quoted[i] = quote(value)
+	}
+	return strings.Join(quoted, ", ")
+}
+
+func mustSetenv(s *backupHooksSuite, key, value string) {
+	if err := os.Setenv(key, value); err != nil {
+		s.Fatalf("setting %s: %v", key, err)
+	}
+}
+
+func restoreEnv(key, value string) {
+	if value == "" {
+		_ = os.Unsetenv(key)
+		return
+	}
+	_ = os.Setenv(key, value)
+}
+
+func lookupSubcommandForHooks(s *backupHooksSuite, name string) *cobra.Command {
+	s.Helper()
+	for _, cmd := range rootCmd.Commands() {
+		if cmd.Name() == name {
+			return cmd
+		}
+	}
+	s.Fatalf("subcommand %q not found", name)
+	return nil
+}
+
+func captureOutput(s *backupHooksSuite, run func() error) (string, string, error) {
+	s.Helper()
+
+	oldStdout := os.Stdout
+	oldStderr := os.Stderr
+	stdoutReader, stdoutWriter, err := os.Pipe()
+	if err != nil {
+		s.Fatalf("creating stdout pipe: %v", err)
+	}
+	stderrReader, stderrWriter, err := os.Pipe()
+	if err != nil {
+		s.Fatalf("creating stderr pipe: %v", err)
+	}
+	os.Stdout = stdoutWriter
+	os.Stderr = stderrWriter
+
+	var stdoutBuf, stderrBuf bytes.Buffer
+	var wg sync.WaitGroup
+	var stdoutErr, stderrErr error
+	wg.Add(2)
+	go func() {
+		defer wg.Done()
+		_, stdoutErr = io.Copy(&stdoutBuf, stdoutReader)
+	}()
+	go func() {
+		defer wg.Done()
+		_, stderrErr = io.Copy(&stderrBuf, stderrReader)
+	}()
+
+	runErr := run()
+	_ = stdoutWriter.Close()
+	_ = stderrWriter.Close()
+	os.Stdout = oldStdout
+	os.Stderr = oldStderr
+	wg.Wait()
+
+	if stdoutErr != nil {
+		s.Fatalf("reading stdout: %v", stdoutErr)
+	}
+	if stderrErr != nil {
+		s.Fatalf("reading stderr: %v", stderrErr)
+	}
+	_ = stdoutReader.Close()
+	_ = stderrReader.Close()
+
+	return stdoutBuf.String(), stderrBuf.String(), runErr
+}

cmd/root.go πŸ”—

@@ -2,6 +2,7 @@ package cmd
 
 import (
 	"context"
+	"errors"
 	"fmt"
 	"os"
 	"slices"
@@ -173,6 +174,10 @@ func mergeOverrides(base, extra map[string][]string) map[string][]string {
 // Execute is the main entry point, called from main.go.
 func Execute() {
 	if err := fang.Execute(context.Background(), rootCmd); err != nil {
+		var exitErr interface{ ExitCode() int }
+		if errors.As(err, &exitErr) {
+			os.Exit(exitErr.ExitCode())
+		}
 		os.Exit(1)
 	}
 }

examples/README.md πŸ”—

@@ -20,6 +20,39 @@ Open `~/.config/keld/config.toml` and customize it for your backups:
 - Configure environment variables for authentication
 - Set backup sources via `_arguments`
 
+Backup presets can also define shell hooks:
+
+```toml
+["media@".backup]
+_arguments = ["/home/user/Music/Final"]
+_pre_hooks = ["echo preparing media backup"]
+_post_hooks = ["echo backup finished with $KELD_RESTIC_STATUS"]
+```
+
+`_pre_hooks` run before `restic backup`. If a pre-hook fails, restic is not
+started and post-hooks do not run. `_post_hooks` run after restic is attempted,
+even when restic fails. Post-hooks receive `KELD_RESTIC_STATUS` and, when restic
+started, `KELD_RESTIC_EXIT_CODE`. Use `keld --show-command --preset <preset>
+backup` to preview configured hooks without running them.
+
+Keld status names are based on [restic's documented exit
+codes](https://restic.readthedocs.io/en/stable/075_scripting.html#exit-codes),
+with one extra status for restic start failures:
+
+| Restic exit code | `KELD_RESTIC_STATUS` | Meaning |
+| ---------------- | -------------------- | ------- |
+| 0                | `success`            | Restic completed successfully. |
+| 1                | `fatal`              | Restic failed; see restic output for details. |
+| 2                | `runtime_error`      | Go runtime error. |
+| 3                | `partial`            | `backup` could not read some source data. |
+| 10               | `repository_missing` | Repository does not exist. |
+| 11               | `locked`             | Restic failed to lock the repository. |
+| 12               | `wrong_password`     | Repository password was wrong. |
+| 130              | `interrupted`        | Restic was interrupted. |
+| 143              | `terminated`         | Restic was terminated by SIGTERM. |
+| other non-zero   | `unknown_failure`    | Unknown restic failure; treat as failed. |
+| restic not started | `start_failed`     | Keld could not start the restic process. |
+
 Repeat this to add backups later.
 
 ### 3. Set up healthchecks.io (optional)

examples/keld/config.toml πŸ”—

@@ -33,6 +33,10 @@ RESTIC_PASSWORD_COMMAND = "op read 'op://Vault/Media Backup/password'"
 
 ["media@".backup]
 _arguments = ["/home/user/Music/Final"]
+# Optional backup hooks are shell commands run around `restic backup`.
+# A failing pre-hook stops the backup; post-hooks run after restic is attempted.
+# _pre_hooks = ["echo preparing generated backup inputs"]
+# _post_hooks = ["echo backup finished with $KELD_RESTIC_STATUS"]
 
 # ── Where to back up ─────────────────────────────────────────
 #

examples/keld/config_long.toml πŸ”—

@@ -19,6 +19,18 @@ exclude-if-present = ".nobackup"
 # Simple preset: `keld --preset home backup`
 _arguments = ["/home/amolith"]
 tag = ["home"]
+# Backup hooks are shell commands run around `restic backup`.
+# Pre-hooks can prepare generated inputs; post-hooks can clean them up or report
+# the outcome. Post-hooks receive KELD_RESTIC_STATUS and, when restic started,
+# KELD_RESTIC_EXIT_CODE.
+# _pre_hooks = [
+#   "mkdir -p /home/amolith/.cache/keld/dumps",
+#   "sqlite3 /home/amolith/app/app.db '.backup /home/amolith/.cache/keld/dumps/app.db'",
+# ]
+# _post_hooks = [
+#   "rm -f /home/amolith/.cache/keld/dumps/app.db",
+#   "echo backup finished with $KELD_RESTIC_STATUS",
+# ]
 
 ["@nas"]
 # Split preset suffix: applies to `* @nas` style presets.
@@ -44,6 +56,11 @@ tag = ["home"]
 # Prefix + command section.
 _arguments = ["/home/amolith", "/etc"]
 exclude-if-present = ".keld-skip"
+# Hook keys follow normal config precedence. Defining them here would replace
+# hooks inherited from lower-priority sections; an empty list clears inherited
+# hooks for matching presets.
+# _pre_hooks = []
+# _post_hooks = []
 
 ["home@".forget]
 # Prefix + different command section.

features/backup_hooks.feature πŸ”—

@@ -0,0 +1,96 @@
+Feature: Backup hooks
+
+  Keld can run backup-specific shell commands before and after restic backup.
+  These hooks let users prepare generated backup inputs, such as database
+  dumps, and clean up generated files after restic has attempted the backup.
+
+  Background:
+    Given the selected preset supplies backup paths
+
+  Rule: Backup hooks are resolved by the most-specific matching configuration
+
+    Scenario: Most-specific hook keys override inherited hook keys
+      Given backup hooks are configured in multiple matching sections
+      When the backup configuration is resolved
+      Then the pre-hooks come from the most-specific section that defines pre-hooks
+      And the post-hooks come from the most-specific section that defines post-hooks
+      And the hooks are not passed to restic as flags
+
+    Scenario: Omitted hook keys inherit less-specific hooks
+      Given backup hooks are configured in a less-specific matching section
+      And the most-specific matching section omits hook keys
+      When the backup configuration is resolved
+      Then the inherited pre-hooks are used
+      And the inherited post-hooks are used
+
+    Scenario: Empty hook lists clear inherited hooks
+      Given backup hooks are configured in a less-specific matching section
+      And the most-specific matching section clears hook keys
+      When the backup configuration is resolved
+      Then no pre-hooks are configured
+      And no post-hooks are configured
+
+  Rule: Pre-hooks run before restic backup
+
+    Scenario: Pre-hooks complete successfully
+      Given the backup configuration includes pre-hooks
+      When keld runs the backup
+      Then each pre-hook runs before restic backup
+      And restic backup is attempted
+
+    Scenario: A pre-hook fails
+      Given the backup configuration includes a failing pre-hook
+      When keld runs the backup
+      Then restic backup is not attempted
+      And post-hooks are not run
+      And keld reports the pre-hook failure
+
+  Rule: Post-hooks run after restic backup is attempted
+
+    Scenario Outline: Post-hooks receive the restic outcome
+      Given the backup configuration includes post-hooks
+      And restic backup exits with code <code>
+      When keld runs the backup
+      Then each post-hook runs after restic backup
+      And each post-hook receives KELD_RESTIC_EXIT_CODE set to "<code>"
+      And each post-hook receives KELD_RESTIC_STATUS set to "<status>"
+      And keld exits with code <code>
+
+      Examples:
+        | code | status             |
+        | 0    | success            |
+        | 1    | fatal              |
+        | 2    | runtime_error      |
+        | 3    | partial            |
+        | 10   | repository_missing |
+        | 11   | locked             |
+        | 12   | wrong_password     |
+        | 130  | interrupted        |
+        | 143  | terminated         |
+        | 99   | unknown_failure    |
+
+    Scenario: A post-hook fails after restic fails
+      Given the backup configuration includes a failing post-hook
+      And restic backup exits with code 3
+      When keld runs the backup
+      Then keld exits with code 3
+      And keld reports the post-hook failure
+
+    Scenario: Restic fails to start after pre-hooks complete
+      Given the backup configuration includes pre-hooks and post-hooks
+      And restic backup fails to start
+      When keld runs the backup
+      Then each post-hook runs after the failed restic start
+      And each post-hook receives KELD_RESTIC_STATUS set to "start_failed"
+      And each post-hook receives no KELD_RESTIC_EXIT_CODE
+      And keld reports the restic start failure
+
+  Rule: Command previews show hooks without runtime-only outcome values
+
+    Scenario: Showing the command for a backup with hooks
+      Given the backup configuration includes pre-hooks and post-hooks
+      When keld shows the resolved backup command
+      Then the output includes the configured pre-hooks
+      And the output includes the configured post-hooks
+      And the output does not include KELD_RESTIC_EXIT_CODE
+      And the output does not include KELD_RESTIC_STATUS

go.mod πŸ”—

@@ -12,12 +12,14 @@ require (
 	github.com/charmbracelet/x/term v0.2.2
 	github.com/dustin/go-humanize v1.0.1
 	github.com/lrstanley/bubbletint/v2 v2.0.1
+	github.com/regen-network/gocuke v1.1.1
 	github.com/spf13/cobra v1.10.2
 	github.com/spf13/pflag v1.0.9
 )
 
 require (
 	github.com/atotto/clipboard v0.1.4 // indirect
+	github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect
 	github.com/catppuccin/go v0.3.0 // indirect
 	github.com/charmbracelet/colorprofile v0.4.2 // indirect
 	github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect
@@ -29,6 +31,12 @@ require (
 	github.com/charmbracelet/x/windows v0.2.2 // indirect
 	github.com/clipperhouse/displaywidth v0.11.0 // indirect
 	github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
+	github.com/cockroachdb/apd/v3 v3.2.1 // indirect
+	github.com/cucumber/gherkin/go/v27 v27.0.0 // indirect
+	github.com/cucumber/messages/go/v22 v22.0.0 // indirect
+	github.com/cucumber/tag-expressions/go/v6 v6.1.0 // indirect
+	github.com/gofrs/uuid v4.4.0+incompatible // indirect
+	github.com/google/go-cmp v0.6.0 // indirect
 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
 	github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
 	github.com/mattn/go-runewidth v0.0.20 // indirect
@@ -43,4 +51,6 @@ require (
 	golang.org/x/sync v0.19.0 // indirect
 	golang.org/x/sys v0.42.0 // indirect
 	golang.org/x/text v0.24.0 // indirect
+	gotest.tools/v3 v3.5.1 // indirect
+	pgregory.net/rapid v1.1.0 // indirect
 )

go.sum πŸ”—

@@ -16,6 +16,8 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z
 github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
 github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
 github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
+github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I=
+github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
 github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
 github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
 github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
@@ -48,15 +50,29 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE
 github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
 github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
 github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
+github.com/cockroachdb/apd/v3 v3.2.1 h1:U+8j7t0axsIgvQUqthuNm82HIrYXodOV2iWLWtEaIwg=
+github.com/cockroachdb/apd/v3 v3.2.1/go.mod h1:klXJcjp+FffLTHlhIG69tezTDvdP065naDsHzKhYSqc=
 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
 github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
 github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
+github.com/cucumber/gherkin/go/v27 v27.0.0 h1:waJh5eeq7rrKn5Gf3/FI4G34ypduPRaV8e370dnupDI=
+github.com/cucumber/gherkin/go/v27 v27.0.0/go.mod h1:2JxwYskO0sO4kumc/Nv1g6bMncT5w0lShuKZnmUIhhk=
+github.com/cucumber/messages/go/v22 v22.0.0 h1:hk3ITpEWQ+KWDe619zYcqtaLOfcu9jgClSeps3DlNWI=
+github.com/cucumber/messages/go/v22 v22.0.0/go.mod h1:aZipXTKc0JnjCsXrJnuZpWhtay93k7Rn3Dee7iyPJjs=
+github.com/cucumber/tag-expressions/go/v6 v6.1.0 h1:YOhnlISh/lyPZrLojFbJVzocv7TGhzOhB9aULN8A7Sg=
+github.com/cucumber/tag-expressions/go/v6 v6.1.0/go.mod h1:6scGHUy3RLnbNq8un7XNoopF2qR/0RMgqolQH/TkycY=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
+github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/lib/pq v1.10.7 h1:p7ZhMD+KsSRozJr34udlUrhboJwWAgCg34+/ZZNvZZw=
+github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
 github.com/lrstanley/bubbletint/v2 v2.0.1 h1:ELxRFzrm9X5DIz7Y1Yp0gfGhsJo+4U3w8WJe6x/Beso=
 github.com/lrstanley/bubbletint/v2 v2.0.1/go.mod h1:fL833lvIEbec7VBi9F8wZ/1008jBiDrvQtuIac9AG/k=
 github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
@@ -77,6 +93,8 @@ github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
 github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/regen-network/gocuke v1.1.1 h1:13D3n5xLbpzA/J2ELHC9jXYq0+XyEr64A3ehjvfmBbE=
+github.com/regen-network/gocuke v1.1.1/go.mod h1:Nl9EbhLmTzdLqb52fr/Fvf8LcoVuTjjf8FlLmXz1zHo=
 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@@ -100,3 +118,7 @@ golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
+gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
+pgregory.net/rapid v1.1.0 h1:CMa0sjHSru3puNx+J0MIAuiiEV4N0qj8/cMWGBBCsjw=
+pgregory.net/rapid v1.1.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=

internal/config/config.go πŸ”—

@@ -14,6 +14,8 @@ const (
 	keyArguments = "_arguments"
 	keyWorkdir   = "_workdir"
 	keyCommand   = "_command"
+	keyPreHooks  = "_pre_hooks"
+	keyPostHooks = "_post_hooks"
 )
 
 // environSuffix marks a section as containing environment variables.
@@ -41,6 +43,12 @@ type ResolvedConfig struct {
 	// Environ holds additional environment variables for the restic process.
 	Environ map[string]string
 
+	// PreHooks are shell commands to run before restic backup.
+	PreHooks []string
+
+	// PostHooks are shell commands to run after restic backup is attempted.
+	PostHooks []string
+
 	// SectionsRead lists which config sections contributed to this resolution.
 	SectionsRead []string
 }
@@ -335,6 +343,16 @@ func assemble(merged map[string]any, command string, environ map[string]string,
 		rc.Command = fmt.Sprint(cmd)
 		delete(merged, keyCommand)
 	}
+	if command == "backup" {
+		if hooks, ok := merged[keyPreHooks]; ok {
+			rc.PreHooks = toStringSlice(hooks)
+		}
+		if hooks, ok := merged[keyPostHooks]; ok {
+			rc.PostHooks = toStringSlice(hooks)
+		}
+	}
+	delete(merged, keyPreHooks)
+	delete(merged, keyPostHooks)
 
 	// Build flags.
 	for k, v := range merged {

internal/restic/exec.go πŸ”—

@@ -2,12 +2,12 @@ package restic
 
 import (
 	"bytes"
+	"errors"
 	"fmt"
 	"os"
 	"os/exec"
 	"sort"
 	"strings"
-	"syscall"
 
 	"git.secluded.site/keld/internal/config"
 )
@@ -23,7 +23,25 @@ var resticNativeCommands = map[string]bool{
 // unset.
 const DefaultExecutable = "restic"
 
-// Run replaces the current process with restic, configured according to cfg.
+// ExitError reports that restic exited unsuccessfully while preserving its
+// numeric exit code for callers that need to mirror restic's process status.
+type ExitError struct {
+	Code int
+	Err  error
+}
+
+func (e *ExitError) Error() string {
+	if e.Err == nil {
+		return fmt.Sprintf("restic exited with code %d", e.Code)
+	}
+	return fmt.Sprintf("restic exited with code %d: %v", e.Code, e.Err)
+}
+
+func (e *ExitError) Unwrap() error { return e.Err }
+
+func (e *ExitError) ExitCode() int { return e.Code }
+
+// Run supervises restic, configured according to cfg.
 func Run(cfg *config.ResolvedConfig) error {
 	exe := executable()
 
@@ -32,20 +50,79 @@ func Run(cfg *config.ResolvedConfig) error {
 		return fmt.Errorf("finding %s: %w", exe, err)
 	}
 
+	if err := resolveEnvironCommands(cfg.Environ, cfg.Workdir); err != nil {
+		return err
+	}
+
+	argv := buildArgv(exe, cfg)
+	env := buildEnv(cfg.Environ)
+
+	for _, hook := range cfg.PreHooks {
+		if err := runHook(hook, cfg.Workdir, env); err != nil {
+			return fmt.Errorf("pre-hook %q: %w", hook, err)
+		}
+	}
+
+	cmd := exec.Command(path, argv[1:]...) //nolint:gosec
+	cmd.Env = env
+	cmd.Stdin = os.Stdin
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
 	if cfg.Workdir != "" {
-		if err := os.Chdir(cfg.Workdir); err != nil {
-			return fmt.Errorf("chdir %s: %w", cfg.Workdir, err)
+		cmd.Dir = cfg.Workdir
+	}
+
+	startErr := cmd.Start()
+	var restErr error
+	restCode := 0
+	restStarted := startErr == nil
+	if startErr == nil {
+		stopSignals := watchResticSignals(cmd.Process)
+		restErr = cmd.Wait()
+		stopSignals()
+		if restErr != nil {
+			var exitErr *exec.ExitError
+			if errors.As(restErr, &exitErr) {
+				restCode = resticExitCode(exitErr)
+			} else {
+				restErr = fmt.Errorf("running restic: %w", restErr)
+			}
 		}
+	} else {
+		restErr = fmt.Errorf("starting restic: %w", startErr)
 	}
 
-	if err := resolveEnvironCommands(cfg.Environ); err != nil {
-		return err
+	postEnv := append([]string(nil), env...)
+	if restStarted {
+		postEnv = append(
+			postEnv,
+			fmt.Sprintf("KELD_RESTIC_EXIT_CODE=%d", restCode),
+			"KELD_RESTIC_STATUS="+ResticStatus(restCode),
+		)
+	} else {
+		postEnv = append(postEnv, "KELD_RESTIC_STATUS=start_failed")
+	}
+	var postErr error
+	for _, hook := range cfg.PostHooks {
+		if err := runHook(hook, cfg.Workdir, postEnv); err != nil {
+			if postErr == nil {
+				postErr = err
+			}
+			fmt.Fprintf(os.Stderr, "post-hook %q failed: %v\n", hook, err)
+		}
 	}
 
-	argv := buildArgv(exe, cfg)
-	env := buildEnv(cfg.Environ)
+	if restErr != nil {
+		if !restStarted {
+			return restErr
+		}
+		return &ExitError{Code: restCode, Err: restErr}
+	}
+	if postErr != nil {
+		return fmt.Errorf("post-hook: %w", postErr)
+	}
 
-	return syscall.Exec(path, argv, env)
+	return nil
 }
 
 // DryRun formats a human-readable summary of what Run would execute.
@@ -75,6 +152,19 @@ func DryRun(cfg *config.ResolvedConfig) string {
 		}
 	}
 
+	if len(cfg.PreHooks) > 0 {
+		fmt.Fprintln(&b, "pre-hooks:")
+		for _, hook := range cfg.PreHooks {
+			fmt.Fprintf(&b, "  %s\n", hook)
+		}
+	}
+	if len(cfg.PostHooks) > 0 {
+		fmt.Fprintln(&b, "post-hooks:")
+		for _, hook := range cfg.PostHooks {
+			fmt.Fprintf(&b, "  %s\n", hook)
+		}
+	}
+
 	argv := buildArgv(executable(), cfg)
 	fmt.Fprintf(&b, "command: %s\n", quotedJoin(argv))
 
@@ -108,6 +198,46 @@ func buildArgv(exe string, cfg *config.ResolvedConfig) []string {
 	return argv
 }
 
+func runHook(hook, workdir string, env []string) error {
+	cmd := exec.Command("sh", "-c", hook) //nolint:gosec
+	cmd.Env = env
+	cmd.Stdin = os.Stdin
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+	if workdir != "" {
+		cmd.Dir = workdir
+	}
+	return cmd.Run()
+}
+
+// ResticStatus maps documented restic backup exit codes to script-friendly
+// status labels. Unknown non-zero exit codes are failures by restic's
+// documented scripting contract.
+func ResticStatus(code int) string {
+	switch code {
+	case 0:
+		return "success"
+	case 1:
+		return "fatal"
+	case 2:
+		return "runtime_error"
+	case 3:
+		return "partial"
+	case 10:
+		return "repository_missing"
+	case 11:
+		return "locked"
+	case 12:
+		return "wrong_password"
+	case 130:
+		return "interrupted"
+	case 143:
+		return "terminated"
+	default:
+		return "unknown_failure"
+	}
+}
+
 // resolveEnvironCommands finds keys ending in _COMMAND in the environ map,
 // executes their values as shell commands, and replaces them with the base key
 // set to the command's stdout (trailing newline stripped). Keys that restic
@@ -115,7 +245,7 @@ func buildArgv(exe string, cfg *config.ResolvedConfig) []string {
 //
 // If both FOO_COMMAND and FOO are present, the command result takes precedence
 // and FOO_COMMAND is removed from the map.
-func resolveEnvironCommands(environ map[string]string) error {
+func resolveEnvironCommands(environ map[string]string, workdir string) error {
 	for key, cmd := range environ {
 		if !strings.HasSuffix(key, config.CommandSuffix) {
 			continue
@@ -133,6 +263,9 @@ func resolveEnvironCommands(environ map[string]string) error {
 		proc := exec.Command("sh", "-c", cmd)
 		proc.Stdout = &stdout
 		proc.Stderr = &stderr
+		if workdir != "" {
+			proc.Dir = workdir
+		}
 
 		if err := proc.Run(); err != nil {
 			return fmt.Errorf(

internal/restic/exec_test.go πŸ”—

@@ -236,7 +236,7 @@ func TestResolveEnvironCommands(t *testing.T) {
 		t.Run(tt.name, func(t *testing.T) {
 			t.Parallel()
 
-			err := resolveEnvironCommands(tt.environ)
+			err := resolveEnvironCommands(tt.environ, "")
 
 			if tt.wantError != "" {
 				if err == nil {

internal/restic/list_snapshots.go πŸ”—

@@ -111,7 +111,7 @@ func ListSnapshots(cfg *config.ResolvedConfig) ([]Snapshot, error) {
 	}
 
 	env := copyEnviron(cfg.Environ)
-	if err := resolveEnvironCommands(env); err != nil {
+	if err := resolveEnvironCommands(env, cfg.Workdir); err != nil {
 		return nil, fmt.Errorf("resolving environ for snapshot listing: %w", err)
 	}
 

internal/restic/lsnodes.go πŸ”—

@@ -88,7 +88,7 @@ func RunLs(cfg *config.ResolvedConfig, snapshotID string) ([]LsNode, error) {
 	}
 
 	env := copyEnviron(cfg.Environ)
-	if err := resolveEnvironCommands(env); err != nil {
+	if err := resolveEnvironCommands(env, cfg.Workdir); err != nil {
 		return nil, fmt.Errorf("resolving environ for ls: %w", err)
 	}
 

internal/restic/signal_other.go πŸ”—

@@ -0,0 +1,33 @@
+//go:build !unix
+
+package restic
+
+import (
+	"os"
+	"os/exec"
+	"os/signal"
+)
+
+func watchResticSignals(process *os.Process) func() {
+	_ = process
+
+	signals := make(chan os.Signal, 1)
+	signal.Notify(signals, os.Interrupt)
+	done := make(chan struct{})
+
+	go func() {
+		defer close(done)
+		for range signals {
+		}
+	}()
+
+	return func() {
+		signal.Stop(signals)
+		close(signals)
+		<-done
+	}
+}
+
+func resticExitCode(exitErr *exec.ExitError) int {
+	return exitErr.ExitCode()
+}

internal/restic/signal_unix.go πŸ”—

@@ -0,0 +1,43 @@
+//go:build unix
+
+package restic
+
+import (
+	"os"
+	"os/exec"
+	"os/signal"
+	"syscall"
+)
+
+func watchResticSignals(process *os.Process) func() {
+	signals := make(chan os.Signal, 1)
+	signal.Notify(signals, os.Interrupt, syscall.SIGTERM)
+	done := make(chan struct{})
+
+	go func() {
+		defer close(done)
+		for sig := range signals {
+			if sig == syscall.SIGTERM {
+				_ = process.Signal(sig)
+			}
+		}
+	}()
+
+	return func() {
+		signal.Stop(signals)
+		close(signals)
+		<-done
+	}
+}
+
+func resticExitCode(exitErr *exec.ExitError) int {
+	if status, ok := exitErr.Sys().(syscall.WaitStatus); ok {
+		if status.Signaled() {
+			return 128 + int(status.Signal())
+		}
+		if status.Exited() {
+			return status.ExitStatus()
+		}
+	}
+	return exitErr.ExitCode()
+}