From 0d191ce3c5f6106532f30be6789fc2d8495a6c3f Mon Sep 17 00:00:00 2001 From: Amolith Date: Fri, 13 Mar 2026 10:43:43 -0600 Subject: [PATCH] Initial commit --- AGENTS.md | 154 +++++ cmd/completions.go | 147 +++++ cmd/completions_test.go | 190 ++++++ cmd/root.go | 289 +++++++++ cmd/root_test.go | 223 +++++++ example/config.toml | 56 ++ go.mod | 37 ++ go.sum | 74 +++ internal/config/config.go | 359 +++++++++++ internal/config/config_test.go | 273 ++++++++ internal/config/files.go | 78 +++ internal/config/files_test.go | 87 +++ internal/config/presets.go | 31 + internal/config/presets_test.go | 83 +++ internal/menu/menu.go | 177 ++++++ internal/restic/exec.go | 97 +++ internal/restic/exec_test.go | 132 ++++ main.go | 7 + mise.toml | 39 ++ restic-cli-catalogue.md | 1055 +++++++++++++++++++++++++++++++ 20 files changed, 3588 insertions(+) create mode 100644 AGENTS.md create mode 100644 cmd/completions.go create mode 100644 cmd/completions_test.go create mode 100644 cmd/root.go create mode 100644 cmd/root_test.go create mode 100644 example/config.toml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/config/files.go create mode 100644 internal/config/files_test.go create mode 100644 internal/config/presets.go create mode 100644 internal/config/presets_test.go create mode 100644 internal/menu/menu.go create mode 100644 internal/restic/exec.go create mode 100644 internal/restic/exec_test.go create mode 100644 main.go create mode 100644 mise.toml create mode 100644 restic-cli-catalogue.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..3b51eb8731ce7d0d86ad2a5b5780e1c6b9ee680c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,154 @@ +# AGENTS.md - Working with Pika + +## Project Overview + +**Pika** is a friendly TOML-configured wrapper around +[restic](https://restic.net/) (a backup tool). It provides: + +- Layered configuration with presets and command-specific overrides +- Interactive menu when invoked without arguments (BubbleTea v2) +- Split preset syntax (`home@cloud`) for composable configurations +- Passthrough of arbitrary flags to restic + +This project uses jujutsu for version control. Before starting work, check `jj +st`. If there's existing work in progress, run `jj new -m "..."` to create a new +working copy with a good, imperative, kernel-commit-style description. DO NOT +follow or read ANY skills or use ANY tools related to Conventional Commits. + +## Essential Commands + +All tasks are managed via **mise** (see `mise.toml`): + +```bash +mise run vuln +mise run vet +mise run install + +# Check formatting without modifying +mise run fmt:check +# Run full check suite (fmt:check, vet, lint, vuln, build, test) +mise run check +``` + +## Current Architecture & Data Flow + +Suggest updating this if implementation changes. + +```text +main.go + └── cmd.Execute() + └── rootCmd.RunE + ├── extractOwnFlags() # --dry-run, --config + ├── parseArgs() # Split into preset, command, + │ # passthrough + ├── runMenu() # BubbleTea if no command + ├── config.Resolve() # Merge TOML sections + CLI overrides + │ ├── DiscoverFiles() # Find config files + │ ├── loadFiles() # Parse and merge TOML + │ ├── buildSectionOrder() # Determine merge precedence + │ ├── interpolate() # Resolve ${section.key} refs + │ └── assemble() # Build ResolvedConfig + ├── config.IsDryRun() + ├── restic.DryRun() # Print what would execute + └── restic.Run() # syscall.Exec to restic +``` + +## Config System + +### Section Merge Order + +Config sections are merged in ascending priority order. For +`pika home@cloud backup`: + +```text +[global] → [global.backup] → [@cloud] → [@cloud.backup] → +[home@] → [home@.backup] → [home@cloud] → [home@cloud.backup] → +CLI overrides +``` + +### Config File Discovery + +1. Default dirs: `/usr/share/pika`, `/etc/pika`, `~/.config/pika` +2. In each dir: `config.toml` then sorted `conf.d/*.toml` +3. `PIKA_CONFIG_PATHS` env var (colon-separated, supports globs) +4. `PIKA_CONFIG_FILE` replaces all above if set + +### 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 | + +### Interpolation + +Values can reference other sections: `cache-dir = "${vars.cache-root}/cache"` + +### Split Presets + +Presets with `@` are split: `home@cloud` → applies `[@cloud]`, `[home@]`, +then `[home@cloud]` + +See `example/config.toml` for comprehensive examples. + +## Code Patterns & Conventions + +### Test Patterns + +- **Table-driven tests** with `t.Parallel()` +- Testdata embedded as string constants (see `resolveFixtureTOML`) +- `tt := tt` copy before parallel subtest +- Use `t.TempDir()` and `t.Setenv()` for isolation + +## Important Gotchas + +### Config Merge Behavior + +- Later files override earlier at **section granularity** +- Within a file, later sections override earlier +- 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: + +- No Go code runs after successful exec +- No deferred functions execute +- Dry-run mode exists specifically to show what would run + +### Test Isolation + +Tests modify `DefaultConfigDirs` global. Always restore with `t.Cleanup()`: + +```go +original := DefaultConfigDirs +DefaultConfigDirs = []string{tmpDir} +t.Cleanup(func() { DefaultConfigDirs = original }) +``` + +### BubbleTea v2 + +Using v2 API (not v1). Key differences: + +- `tea.NewProgram(m)` instead of `tea.NewProgram(m, opts...)` +- `tea.RequestBackgroundColor` for theme detection +- `tea.NewView()` instead of returning strings + +## Environment Variables + +| Variable | Purpose | +| ------------------- | --------------------------------------- | +| `PIKA_CONFIG_FILE` | Single config file (highest priority) | +| `PIKA_CONFIG_PATHS` | Colon-separated additional config paths | +| `PIKA_DRYRUN` | Set to enable dry-run mode | +| `PIKA_EXECUTABLE` | Override restic binary path | + +## Development Workflow + +1. Make changes +2. `mise run check` - full validation +3. Test manually: `./pika --dry-run ` diff --git a/cmd/completions.go b/cmd/completions.go new file mode 100644 index 0000000000000000000000000000000000000000..940e18983c7632236f10eb95528280870c3b8b20 --- /dev/null +++ b/cmd/completions.go @@ -0,0 +1,147 @@ +package cmd + +import ( + "slices" + + "github.com/spf13/cobra" + + "git.secluded.site/pika/internal/config" +) + +// knownCommands lists the restic subcommands pika knows about. This is the +// same set shown in the interactive menu (minus "quit"). +var knownCommands = []string{ + "backup", + "check", + "forget", + "init", + "restore", + "snapshots", +} + +// pikaFlags lists pika's own flags (the ones extracted in extractOwnFlags). +// Cobra can't advertise these automatically because DisableFlagParsing is on. +var pikaFlags = []string{ + "--config", + "--dry-run", + "--help", +} + +func init() { + rootCmd.ValidArgsFunction = completeArgs +} + +// completeArgs provides dynamic shell completions for pika. +// +// It examines how many positional (non-flag) arguments have already been +// provided and offers: +// +// - 0 positionals so far → presets (from TOML config) + known commands +// - 1 positional so far → if it's a preset, offer commands; if it's +// already a command, no more positional completions +// - 2+ positionals → no further positional completions +// +// When the current word starts with "-", pika's own flags are offered instead. +func completeArgs(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + // When the user is typing a flag, offer pika's own flags. + if len(toComplete) > 0 && toComplete[0] == '-' { + return pikaFlags, cobra.ShellCompDirectiveNoFileComp + } + + // Count how many positional args have already been accepted (i.e. are + // in args, not toComplete). We need to skip flag tokens and their + // values, just like parseArgs does. + positionals := countPositionals(args) + + switch positionals { + case 0: + // Nothing committed yet — offer presets and commands. + return presetsAndCommands(), cobra.ShellCompDirectiveNoFileComp + case 1: + // One positional committed. If it's a known command, there's + // nothing more to complete positionally (the rest is restic + // flags, which we don't complete). + if isKnownCommand(args) { + return nil, cobra.ShellCompDirectiveNoFileComp + } + // Otherwise the first positional was a preset; offer commands. + return knownCommands, cobra.ShellCompDirectiveNoFileComp + default: + return nil, cobra.ShellCompDirectiveNoFileComp + } +} + +// presetsAndCommands merges config presets with the known command list, +// deduplicating any overlap. +func presetsAndCommands() []string { + presets := config.Presets() + seen := make(map[string]struct{}, len(presets)+len(knownCommands)) + + out := make([]string, 0, len(presets)+len(knownCommands)) + for _, p := range presets { + if _, ok := seen[p]; ok { + continue + } + seen[p] = struct{}{} + out = append(out, p) + } + for _, c := range knownCommands { + if _, ok := seen[c]; ok { + continue + } + seen[c] = struct{}{} + out = append(out, c) + } + return out +} + +// countPositionals returns the number of positional (non-flag) tokens in args. +func countPositionals(args []string) int { + n := 0 + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case arg == "--": + // Everything after -- is positional. + n += len(args) - i - 1 + return n + case isFlagToken(arg): + // Skip the flag's value if it has one. + if !hasEqualSign(arg) && i+1 < len(args) && !isFlagToken(args[i+1]) && args[i+1] != "--" { + i++ + } + default: + n++ + } + } + return n +} + +// isKnownCommand reports whether the first positional arg in the already- +// accepted args is a known restic command. +func isKnownCommand(args []string) bool { + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case arg == "--": + return false + case isFlagToken(arg): + if !hasEqualSign(arg) && i+1 < len(args) && !isFlagToken(args[i+1]) && args[i+1] != "--" { + i++ + } + default: + // First positional — check if it's a command. + return slices.Contains(knownCommands, arg) + } + } + return false +} + +func hasEqualSign(arg string) bool { + for _, c := range arg { + if c == '=' { + return true + } + } + return false +} diff --git a/cmd/completions_test.go b/cmd/completions_test.go new file mode 100644 index 0000000000000000000000000000000000000000..0cbb75681c79f25d8d5b89d04554ff0e40dc9ea2 --- /dev/null +++ b/cmd/completions_test.go @@ -0,0 +1,190 @@ +package cmd + +import ( + "os" + "path/filepath" + "reflect" + "sort" + "testing" + + "github.com/spf13/cobra" +) + +// setupCompletionConfig writes a small TOML fixture and points PIKA_CONFIG_FILE +// at it, so config.Presets() returns deterministic results. +func setupCompletionConfig(t *testing.T) { + t.Helper() + + dir := t.TempDir() + cfg := filepath.Join(dir, "config.toml") + err := os.WriteFile(cfg, []byte(` +[global] +verbose = true + +[home] +repo = "/repos/home" + +["home@cloud"] +repo = "/repos/cloud" + +["@nas"] +repo = "/repos/nas" +`), 0o600) + if err != nil { + t.Fatalf("writing fixture config: %v", err) + } + t.Setenv("PIKA_CONFIG_FILE", cfg) + t.Setenv("HOME", dir) +} + +func TestCompleteArgsNoArgs(t *testing.T) { + setupCompletionConfig(t) + + completions, directive := completeArgs(nil, nil, "") + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Fatalf("expected NoFileComp directive, got %v", directive) + } + + // Should contain both presets and commands. + has := make(map[string]bool) + for _, c := range completions { + has[c] = true + } + + for _, want := range []string{"home", "home@cloud", "@nas", "backup", "restore", "snapshots"} { + if !has[want] { + t.Errorf("missing expected completion %q in %v", want, completions) + } + } + // "global" must not appear. + if has["global"] { + t.Errorf("completions should not include 'global', got %v", completions) + } +} + +func TestCompleteArgsAfterPreset(t *testing.T) { + setupCompletionConfig(t) + + completions, directive := completeArgs(nil, []string{"home"}, "") + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Fatalf("expected NoFileComp, got %v", directive) + } + sort.Strings(completions) + if !reflect.DeepEqual(completions, knownCommands) { + t.Fatalf("after preset, expected commands %v, got %v", knownCommands, completions) + } +} + +func TestCompleteArgsAfterCommand(t *testing.T) { + setupCompletionConfig(t) + + completions, directive := completeArgs(nil, []string{"backup"}, "") + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Fatalf("expected NoFileComp, got %v", directive) + } + if len(completions) != 0 { + t.Fatalf("after command, expected no completions, got %v", completions) + } +} + +func TestCompleteArgsAfterPresetAndCommand(t *testing.T) { + setupCompletionConfig(t) + + completions, directive := completeArgs(nil, []string{"home", "backup"}, "") + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Fatalf("expected NoFileComp, got %v", directive) + } + if len(completions) != 0 { + t.Fatalf("after preset+command, expected no completions, got %v", completions) + } +} + +func TestCompleteArgsFlagPrefix(t *testing.T) { + t.Parallel() + + // No config needed — flag completions are static. + completions, directive := completeArgs(nil, nil, "--") + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Fatalf("expected NoFileComp, got %v", directive) + } + sort.Strings(completions) + want := []string{"--config", "--dry-run", "--help"} + if !reflect.DeepEqual(completions, want) { + t.Fatalf("flag completions mismatch: got %v, want %v", completions, want) + } +} + +func TestCompleteArgsSkipsFlags(t *testing.T) { + setupCompletionConfig(t) + + // Simulate: pika --config ./pika.toml + // The flag and its value should not count as positionals, so we + // should still be at 0 positionals → presets + commands. + completions, directive := completeArgs(nil, []string{"--config", "./pika.toml"}, "") + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Fatalf("expected NoFileComp, got %v", directive) + } + + has := make(map[string]bool) + for _, c := range completions { + has[c] = true + } + if !has["backup"] { + t.Errorf("expected commands after flag+value, got %v", completions) + } + if !has["home"] { + t.Errorf("expected presets after flag+value, got %v", completions) + } +} + +func TestCountPositionals(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + want int + }{ + {name: "empty", args: nil, want: 0}, + {name: "one positional", args: []string{"backup"}, want: 1}, + {name: "two positionals", args: []string{"home", "backup"}, want: 2}, + {name: "flag with value", args: []string{"--repo", "/repo"}, want: 0}, + {name: "flag=value", args: []string{"--repo=/repo"}, want: 0}, + {name: "mixed", args: []string{"--repo", "/repo", "home", "backup"}, want: 2}, + {name: "double dash", args: []string{"home", "--", "a", "b"}, want: 3}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := countPositionals(tt.args) + if got != tt.want { + t.Fatalf("countPositionals(%#v) = %d, want %d", tt.args, got, tt.want) + } + }) + } +} + +func TestIsKnownCommand(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + want bool + }{ + {name: "empty", args: nil, want: false}, + {name: "command first", args: []string{"backup"}, want: true}, + {name: "preset first", args: []string{"home"}, want: false}, + {name: "flag then command", args: []string{"--repo", "/repo", "backup"}, want: true}, + {name: "flag then preset", args: []string{"--repo", "/repo", "home"}, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := isKnownCommand(tt.args) + if got != tt.want { + t.Fatalf("isKnownCommand(%#v) = %v, want %v", tt.args, got, tt.want) + } + }) + } +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000000000000000000000000000000000000..b2451ec5c3817100855eb1b59f36fa41bd2aab97 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,289 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/fang/v2" + "github.com/spf13/cobra" + + "git.secluded.site/pika/internal/config" + "git.secluded.site/pika/internal/menu" + "git.secluded.site/pika/internal/restic" +) + +var ( + flagDryRun bool + flagConfigFile string +) + +const overrideArgumentsKey = "_arguments" + +// menuItems defines the interactive command picker shown when pika is invoked +// with no arguments. +var menuItems = []menu.Item{ + {Label: "backup", Hotkey: 'b'}, + {Label: "restore", Hotkey: 'r'}, + {Label: "snapshots", Hotkey: 's'}, + {Label: "forget", Hotkey: 'f'}, + {Label: "check", Hotkey: 'c'}, + {Label: "init", Hotkey: 'i'}, + {Label: "quit", Hotkey: 'q'}, +} + +var rootCmd = &cobra.Command{ + Use: "pika [preset] [restic flags...]", + Short: "A friendly wrapper around restic", + Long: "pika resolves layered TOML config presets and executes restic with the merged result.", + Example: ` pika backup + pika home backup + pika home@nas backup --tag daily --verbose + pika --config ./pika.toml home backup + pika --dry-run home backup + pika --help`, + + // Accept arbitrary args — we parse preset/command ourselves. + Args: cobra.ArbitraryArgs, + DisableFlagParsing: true, + SilenceUsage: true, + SilenceErrors: true, + + RunE: func(cmd *cobra.Command, args []string) error { + // Re-parse our own flags out of the raw args since cobra flag + // parsing is disabled (we need to pass unknown flags through to + // restic). + args = extractOwnFlags(args) + + if hasHelpFlag(args) { + return cmd.Help() + } + + if flagConfigFile != "" { + if err := os.Setenv("PIKA_CONFIG_FILE", flagConfigFile); err != nil { + return fmt.Errorf("setting PIKA_CONFIG_FILE: %w", err) + } + } + if flagDryRun { + if err := os.Setenv("PIKA_DRYRUN", "1"); err != nil { + return fmt.Errorf("setting PIKA_DRYRUN: %w", err) + } + } + + preset, command, passthrough := parseArgs(args) + + // No command → interactive menu. + if command == "" { + selected, err := runMenu() + if err != nil { + return fmt.Errorf("menu: %w", err) + } + if selected == "" || selected == "quit" { + return nil + } + command = selected + } + + overrides := parsePassthrough(passthrough) + + cfg, err := config.Resolve(preset, command, overrides) + if err != nil { + return err + } + + if config.IsDryRun() { + fmt.Print(restic.DryRun(cfg)) + return nil + } + + return restic.Run(cfg) + }, +} + +// Execute is the main entry point, called from main.go. +func Execute() { + if err := fang.Execute(context.Background(), rootCmd); err != nil { + os.Exit(1) + } +} + +// extractOwnFlags pulls --dry-run and --config out of the raw arg list, +// setting package-level flags, and returns the remaining args. +func extractOwnFlags(args []string) []string { + var rest []string + for i := 0; i < len(args); i++ { + switch args[i] { + case "--dry-run": + flagDryRun = true + case "--config": + if i+1 < len(args) { + i++ + flagConfigFile = args[i] + } + case "--help", "-h": + // Keep help flags in the raw list so RunE can dispatch help. + rest = append(rest, args[i]) + default: + rest = append(rest, args[i]) + } + } + return rest +} + +func hasHelpFlag(args []string) bool { + for _, arg := range args { + if arg == "--help" || arg == "-h" { + return true + } + } + return false +} + +// parseArgs splits args into (preset, command, remaining) using crestic-style +// positional arity. +// +// Rules: +// - 0 positional args: no preset, no command. +// - 1 positional arg: command only. +// - 2+ positional args: first is preset, second is command. +// +// All unconsumed args are returned as passthrough for parsePassthrough. +func parseArgs(args []string) (preset, command string, rest []string) { + positionals := positionalIndices(args) + if len(positionals) == 0 { + if len(args) == 0 { + return "", "", nil + } + return "", "", append([]string(nil), args...) + } + + consumed := make(map[int]struct{}, 2) + if len(positionals) == 1 { + idx := positionals[0] + command = args[idx] + consumed[idx] = struct{}{} + } else { + presetIdx := positionals[0] + commandIdx := positionals[1] + preset = args[presetIdx] + command = args[commandIdx] + consumed[presetIdx] = struct{}{} + consumed[commandIdx] = struct{}{} + } + + rest = make([]string, 0, len(args)-len(consumed)) + for i, arg := range args { + if _, ok := consumed[i]; ok { + continue + } + rest = append(rest, arg) + } + + return preset, command, rest +} + +// parsePassthrough converts restic-style CLI flags into a map suitable for +// config.Resolve's cliOverrides parameter. +func parsePassthrough(args []string) map[string][]string { + if len(args) == 0 { + return nil + } + + overrides := make(map[string][]string) + var positional []string + for i := 0; i < len(args); i++ { + arg := args[i] + if arg == "--" { + if i+1 < len(args) { + positional = append(positional, args[i+1:]...) + } + break + } + + if !isFlagToken(arg) { + positional = append(positional, arg) + continue + } + + name := strings.TrimLeft(arg, "-") + + // --flag=value form + if eqIdx := strings.IndexByte(name, '='); eqIdx >= 0 { + overrides[name[:eqIdx]] = append(overrides[name[:eqIdx]], name[eqIdx+1:]) + continue + } + + // Boolean flag (no next arg, or next arg is also a flag) + if i+1 >= len(args) || args[i+1] == "--" || isFlagToken(args[i+1]) { + if _, ok := overrides[name]; !ok { + overrides[name] = nil + } + continue + } + + // --flag value form + i++ + overrides[name] = append(overrides[name], args[i]) + } + + if len(positional) > 0 { + overrides[overrideArgumentsKey] = append([]string(nil), positional...) + } + + if len(overrides) == 0 { + return nil + } + + return overrides +} + +// positionalIndices returns indices of positional arguments in args. Flag +// values are treated as part of their flag, not positional args. +func positionalIndices(args []string) []int { + var idx []int + + for i := 0; i < len(args); i++ { + arg := args[i] + + switch { + case arg == "--": + for j := i + 1; j < len(args); j++ { + idx = append(idx, j) + } + return idx + case isFlagToken(arg): + if strings.Contains(arg, "=") { + continue + } + if i+1 < len(args) && args[i+1] != "--" && !isFlagToken(args[i+1]) { + i++ + } + default: + idx = append(idx, i) + } + } + + return idx +} + +func isFlagToken(arg string) bool { + return strings.HasPrefix(arg, "-") && arg != "-" && arg != "--" +} + +// runMenu launches the interactive BubbleTea menu and returns the chosen +// command, or "" if the user quit. +func runMenu() (string, error) { + m := menu.New(menuItems) + p := tea.NewProgram(m) + result, err := p.Run() + if err != nil { + return "", err + } + model, ok := result.(menu.Model) + if !ok { + return "", fmt.Errorf("unexpected menu model type %T", result) + } + return model.Choice(), nil +} diff --git a/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 0000000000000000000000000000000000000000..ce4c36c6eec59e97ff9971ab648b571642735c44 --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,223 @@ +package cmd + +import ( + "reflect" + "testing" +) + +func TestParseArgs(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + wantPreset string + wantCommand string + wantRest []string + }{ + { + name: "no args", + args: nil, + wantPreset: "", + wantCommand: "", + wantRest: nil, + }, + { + name: "single positional is command", + args: []string{"backup"}, + wantPreset: "", + wantCommand: "backup", + wantRest: nil, + }, + { + name: "single split-like token is still command", + args: []string{"home@nas"}, + wantPreset: "", + wantCommand: "home@nas", + wantRest: nil, + }, + { + name: "two positionals are preset and command", + args: []string{"home", "backup"}, + wantPreset: "home", + wantCommand: "backup", + wantRest: nil, + }, + { + name: "two positionals plus passthrough", + args: []string{"home@nas", "backup", "--tag", "daily", "/src"}, + wantPreset: "home@nas", + wantCommand: "backup", + wantRest: []string{"--tag", "daily", "/src"}, + }, + { + name: "single command with flags", + args: []string{"backup", "--repo", "/repo", "--json"}, + wantPreset: "", + wantCommand: "backup", + wantRest: []string{"--repo", "/repo", "--json"}, + }, + { + name: "flags before command", + args: []string{"--repo", "/repo", "backup", "--json"}, + wantPreset: "", + wantCommand: "backup", + wantRest: []string{"--repo", "/repo", "--json"}, + }, + { + name: "no positional command when only flags provided", + args: []string{"--repo", "/repo", "--json"}, + wantPreset: "", + wantCommand: "", + wantRest: []string{"--repo", "/repo", "--json"}, + }, + { + name: "double-dash keeps following literals in passthrough", + args: []string{"home", "backup", "--", "--literal", "path"}, + wantPreset: "home", + wantCommand: "backup", + wantRest: []string{"--", "--literal", "path"}, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + gotPreset, gotCommand, gotRest := parseArgs(tt.args) + if gotPreset != tt.wantPreset { + t.Fatalf("preset mismatch: got %q, want %q", gotPreset, tt.wantPreset) + } + if gotCommand != tt.wantCommand { + t.Fatalf("command mismatch: got %q, want %q", gotCommand, tt.wantCommand) + } + if !equalStringSlices(gotRest, tt.wantRest) { + t.Fatalf("rest mismatch: got %#v, want %#v", gotRest, tt.wantRest) + } + }) + } +} + +func TestParsePassthrough(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + want map[string][]string + }{ + { + name: "no passthrough", + args: nil, + want: nil, + }, + { + name: "flag with value", + args: []string{"--repo", "/repo"}, + want: map[string][]string{ + "repo": {"/repo"}, + }, + }, + { + name: "repeated and equals flags", + args: []string{"--tag=daily", "--tag", "weekly"}, + want: map[string][]string{ + "tag": {"daily", "weekly"}, + }, + }, + { + name: "boolean flags", + args: []string{"-v", "--json"}, + want: map[string][]string{ + "v": nil, + "json": nil, + }, + }, + { + name: "positional arguments become _arguments", + args: []string{"/src", "/dst"}, + want: map[string][]string{ + overrideArgumentsKey: {"/src", "/dst"}, + }, + }, + { + name: "mixed flags and positional arguments", + args: []string{"--repo", "/repo", "/src", "/dst"}, + want: map[string][]string{ + "repo": {"/repo"}, + overrideArgumentsKey: {"/src", "/dst"}, + }, + }, + { + name: "double-dash preserves literal arguments", + args: []string{"--repo", "/repo", "--", "--literal", "path"}, + want: map[string][]string{ + "repo": {"/repo"}, + overrideArgumentsKey: {"--literal", "path"}, + }, + }, + { + name: "single dash is positional argument", + args: []string{"-"}, + want: map[string][]string{ + overrideArgumentsKey: {"-"}, + }, + }, + { + name: "flag without value before another flag is boolean", + args: []string{"--host", "--json"}, + want: map[string][]string{ + "host": nil, + "json": nil, + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := parsePassthrough(tt.args) + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("overrides mismatch: got %#v, want %#v", got, tt.want) + } + }) + } +} + +func TestHasHelpFlag(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + want bool + }{ + {name: "no args", args: nil, want: false}, + {name: "no help token", args: []string{"backup", "--repo", "/repo"}, want: false}, + {name: "short help", args: []string{"-h"}, want: true}, + {name: "long help", args: []string{"--help"}, want: true}, + {name: "help mixed with args", args: []string{"home", "backup", "--help"}, want: true}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := hasHelpFlag(tt.args) + if got != tt.want { + t.Fatalf("hasHelpFlag(%#v) = %v, want %v", tt.args, got, tt.want) + } + }) + } +} + +func equalStringSlices(a, b []string) bool { + if len(a) == 0 && len(b) == 0 { + return true + } + return reflect.DeepEqual(a, b) +} diff --git a/example/config.toml b/example/config.toml new file mode 100644 index 0000000000000000000000000000000000000000..32dc8765318d72939512668f440a91d7d8766a42 --- /dev/null +++ b/example/config.toml @@ -0,0 +1,56 @@ +# pika config merge order (lowest to highest precedence): +# [global] -> [global.] -> split preset parts -> full preset -> CLI overrides +# +# For a split preset like `home@cloud` and command `backup`, pika checks: +# [global] -> [global.backup] -> [@cloud] -> [@cloud.backup] -> +# [home@] -> [home@.backup] -> [home@cloud] -> [home@cloud.backup] + +[global] +# Shared flags for every command and preset. +password-file = "~/.config/restic/password.txt" +repository = "sftp:restic@example.org:/srv/restic" + +[global.backup] +# Command-specific defaults. +exclude-file = "~/.config/restic/excludes.txt" +exclude-if-present = ".nobackup" + +[home.backup] +# Simple preset: `pika home backup` +_arguments = ["/home/amolith"] +tag = ["home"] + +["@nas"] +# Split preset suffix: applies to `* @nas` style presets. +repository = "sftp:nas@example.org:/mnt/backups/restic" +tag = ["nas"] + +["@cloud"] +# Split preset suffix for cloud backups. +repository = "s3:s3.us-east-1.amazonaws.com/my-restic-bucket" +tag = ["cloud"] + +["@cloud".environ] +# Environment variables are loaded from `.environ` sections. +AWS_PROFILE = "restic-cloud" +RESTIC_PASSWORD_COMMAND = "pass show backups/restic-cloud" + +["home@"] +# Split preset prefix: applies to `home@*` presets. +host = "amolith-laptop" +tag = ["home"] + +["home@".backup] +# Prefix + command section. +_arguments = ["/home/amolith", "/etc"] +exclude-if-present = ".pika-skip" + +["home@".forget] +# Prefix + different command section. +keep-daily = 7 +keep-weekly = 5 +keep-monthly = 12 + +["home@cloud"] +# Full split preset section can override prefix/suffix values. +repository = "s3:s3.us-east-1.amazonaws.com/my-restic-home-cloud" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000000000000000000000000000000000000..aee7b8eaa0d52b161bf695a238fb8d1a6c913aef --- /dev/null +++ b/go.mod @@ -0,0 +1,37 @@ +module git.secluded.site/pika + +go 1.26.1 + +require ( + charm.land/bubbletea/v2 v2.0.2 + charm.land/fang/v2 v2.0.1 + charm.land/lipgloss/v2 v2.0.2 + github.com/BurntSushi/toml v1.6.0 + github.com/spf13/cobra v1.10.2 +) + +require ( + github.com/charmbracelet/colorprofile v0.4.2 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + 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/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 + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/mango v0.1.0 // indirect + github.com/muesli/mango-cobra v1.2.0 // indirect + github.com/muesli/mango-pflag v0.1.0 // indirect + github.com/muesli/roff v0.1.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.24.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000000000000000000000000000000000000..b91ce4064cd169121979b01b9a77e0d005d99fd6 --- /dev/null +++ b/go.sum @@ -0,0 +1,74 @@ +charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= +charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= +charm.land/fang/v2 v2.0.1 h1:zQCM8JQJ1JnQX/66B5jlCYBUxL2as5JXQZ2KJ6EL0mY= +charm.land/fang/v2 v2.0.1/go.mod h1:S1GmkpcvK+OB5w9caywUnJcsMew45Ot8FXqoz8ALrII= +charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs= +charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +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/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= +github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= +github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= +github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0= +github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +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/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= +github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI= +github.com/muesli/mango v0.1.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4= +github.com/muesli/mango-cobra v1.2.0 h1:DQvjzAM0PMZr85Iv9LIMaYISpTOliMEg+uMFtNbYvWg= +github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA= +github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg= +github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= +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/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= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +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= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000000000000000000000000000000000000..91c09880698864943fa720ac0a53d317ecb2a723 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,359 @@ +package config + +import ( + "fmt" + "os" + "regexp" + "strings" + + "github.com/BurntSushi/toml" +) + +// Special keys recognised during option assembly. +const ( + keyArguments = "_arguments" + keyWorkdir = "_workdir" + keyCommand = "_command" +) + +// environSuffix marks a section as containing environment variables. +const environSuffix = ".environ" + +// interpolateRe matches ${section.key} references for cross-section interpolation. +var interpolateRe = regexp.MustCompile(`\$\{([^.}]+)\.([^}]+)\}`) + +// ResolvedConfig holds the fully-merged result of config resolution, ready for +// the restic exec layer to consume. +type ResolvedConfig struct { + // Command is the restic subcommand to run (may be aliased via _command). + Command string + + // Flags maps flag names to their values. Multi-value flags have multiple + // entries. Boolean flags (true) are represented as a nil slice. + Flags []Flag + + // Arguments are positional args passed after the flags. + Arguments []string + + // Workdir is the directory to chdir into before exec, or "" for cwd. + Workdir string + + // Environ holds additional environment variables for the restic process. + Environ map[string]string + + // SectionsRead lists which config sections contributed to this resolution. + SectionsRead []string +} + +// Flag is a single CLI flag to pass to restic. +type Flag struct { + Name string + Value string // empty for boolean switches +} + +// rawConfig is the entire parsed TOML file as nested string-keyed maps. +type rawConfig map[string]any + +// Resolve loads all discovered config files, merges sections according to +// preset/command rules, and returns a ResolvedConfig. +// +// cliOverrides are applied last and should use flag names without leading +// dashes (e.g. "exclude" not "--exclude"). Each key maps to one or more values; +// boolean switches use a nil/empty slice. +func Resolve(preset, command string, cliOverrides map[string][]string) (*ResolvedConfig, error) { + files := DiscoverFiles() + return resolveFrom(files, preset, command, cliOverrides) +} + +func resolveFrom(files []string, preset, command string, cliOverrides map[string][]string) (*ResolvedConfig, error) { + raw, err := loadFiles(files) + if err != nil { + return nil, err + } + + sections := buildSectionOrder(preset, command) + envSections := make([]string, len(sections)) + for i, s := range sections { + envSections[i] = s + environSuffix + } + + // Merge option sections in order. + merged := make(map[string]any) + var sectionsRead []string + for _, sect := range sections { + tbl, ok := lookupSection(raw, sect) + if !ok { + continue + } + // Skip sub-tables (nested commands / environ) — only merge leaf keys. + for k, v := range tbl { + if _, isMap := v.(map[string]any); isMap { + continue + } + merged[k] = v + } + sectionsRead = append(sectionsRead, sect) + } + + // Merge environ sections. + environ := make(map[string]string) + for _, sect := range envSections { + tbl, ok := lookupSection(raw, sect) + if !ok { + continue + } + for k, v := range tbl { + environ[k] = ExpandPath(fmt.Sprint(v)) + } + } + + // Perform cross-section interpolation on merged values. + interpolate(merged, raw) + + // Apply CLI overrides last. + for k, vals := range cliOverrides { + if len(vals) == 0 { + merged[k] = true + } else if len(vals) == 1 { + merged[k] = vals[0] + } else { + iface := make([]any, len(vals)) + for i, v := range vals { + iface[i] = v + } + merged[k] = iface + } + } + + return assemble(merged, command, environ, sectionsRead), nil +} + +// loadFiles reads and merges all TOML config files. Later files override +// earlier ones at the top-level section granularity. +func loadFiles(files []string) (rawConfig, error) { + combined := make(rawConfig) + for _, f := range files { + data, err := os.ReadFile(f) + if err != nil { + if os.IsNotExist(err) { + continue + } + return nil, fmt.Errorf("reading config %s: %w", f, err) + } + var parsed rawConfig + if err := toml.Unmarshal(data, &parsed); err != nil { + return nil, fmt.Errorf("parsing config %s: %w", f, err) + } + // Merge top-level keys; later files win. + for k, v := range parsed { + if existing, ok := combined[k]; ok { + if eMap, eOk := existing.(map[string]any); eOk { + if vMap, vOk := v.(map[string]any); vOk { + for mk, mv := range vMap { + eMap[mk] = mv + } + continue + } + } + } + combined[k] = v + } + } + return combined, nil +} + +// buildSectionOrder returns the list of config sections to read, in ascending +// priority order, for the given preset and command. +// +// For a plain preset "foo" with command "backup": +// +// [global] -> [global.backup] -> [foo] -> [foo.backup] +// +// For a split preset "home@nas" with command "backup": +// +// [global] -> [global.backup] -> [@nas] -> [@nas.backup] -> +// [home@] -> [home@.backup] -> [home@nas] -> [home@nas.backup] +func buildSectionOrder(preset, command string) []string { + sections := []string{"global"} + if command != "" { + sections = append(sections, "global."+command) + } + + if preset == "" { + return sections + } + + if idx := strings.Index(preset, "@"); idx >= 0 { + // Split preset: "prefix@suffix" + // Parts in reverse order of the split, then the full preset. + parts := splitPreset(preset) + for _, part := range parts { + sections = append(sections, part) + if command != "" { + sections = append(sections, part+"."+command) + } + } + // Full preset (only if not already the sole part). + if len(parts) != 1 || parts[0] != preset { + sections = append(sections, preset) + if command != "" { + sections = append(sections, preset+"."+command) + } + } + } else { + sections = append(sections, preset) + if command != "" { + sections = append(sections, preset+"."+command) + } + } + + return sections +} + +// splitPreset splits "prefix@suffix" into ["@suffix", "prefix@"] — the two +// halves that get their own section lookups before the full preset. +func splitPreset(preset string) []string { + idx := strings.Index(preset, "@") + if idx < 0 { + return []string{preset} + } + suffix := preset[idx:] // "@nas" + prefix := preset[:idx+1] // "home@" + + return []string{suffix, prefix} +} + +// lookupSection finds a dotted section name in the raw config tree. +// "global.backup" looks up raw["global"]["backup"]. +func lookupSection(raw rawConfig, section string) (map[string]any, bool) { + parts := strings.Split(section, ".") + var current any = (map[string]any)(raw) + for _, p := range parts { + m, ok := current.(map[string]any) + if !ok { + return nil, false + } + current, ok = m[p] + if !ok { + return nil, false + } + } + m, ok := current.(map[string]any) + if !ok { + return nil, false + } + return m, true +} + +// interpolate resolves ${section.key} references in merged values. +func interpolate(merged map[string]any, raw rawConfig) { + for k, v := range merged { + s, ok := v.(string) + if !ok { + continue + } + merged[k] = interpolateRe.ReplaceAllStringFunc(s, func(match string) string { + sub := interpolateRe.FindStringSubmatch(match) + if len(sub) != 3 { + return match + } + sect, key := sub[1], sub[2] + tbl, ok := lookupSection(raw, sect) + if !ok { + return match + } + val, ok := tbl[key] + if !ok { + return match + } + return fmt.Sprint(val) + }) + } +} + +// assemble converts the merged key-value map into a ResolvedConfig. +func assemble(merged map[string]any, command string, environ map[string]string, sectionsRead []string) *ResolvedConfig { + rc := &ResolvedConfig{ + Command: command, + Environ: environ, + SectionsRead: sectionsRead, + } + + // Extract special keys. + if args, ok := merged[keyArguments]; ok { + rc.Arguments = toStringSlice(args) + delete(merged, keyArguments) + } + if wd, ok := merged[keyWorkdir]; ok { + rc.Workdir = ExpandPath(fmt.Sprint(wd)) + delete(merged, keyWorkdir) + } + if cmd, ok := merged[keyCommand]; ok { + rc.Command = fmt.Sprint(cmd) + delete(merged, keyCommand) + } + + // Build flags. + for k, v := range merged { + switch val := v.(type) { + case bool: + if val { + rc.Flags = append(rc.Flags, Flag{Name: flagName(k)}) + } + case []any: + for _, elem := range val { + rc.Flags = append(rc.Flags, Flag{ + Name: flagName(k), + Value: fmt.Sprint(elem), + }) + } + default: + s := fmt.Sprint(val) + // Multi-line string values (newline-separated) become repeated flags. + lines := strings.Split(s, "\n") + for _, line := range lines { + rc.Flags = append(rc.Flags, Flag{ + Name: flagName(k), + Value: ExpandPath(line), + }) + } + } + } + + // Expand path references in arguments. + for i, a := range rc.Arguments { + rc.Arguments[i] = ExpandPath(a) + } + + return rc +} + +// flagName returns the CLI flag form of a key: single-char keys get "-k", +// longer keys get "--key". +func flagName(key string) string { + if len(key) == 1 { + return "-" + key + } + return "--" + key +} + +// toStringSlice coerces a value (string or []any) into []string. +func toStringSlice(v any) []string { + switch val := v.(type) { + case string: + return strings.Fields(val) + case []any: + out := make([]string, 0, len(val)) + for _, elem := range val { + out = append(out, fmt.Sprint(elem)) + } + return out + default: + return []string{fmt.Sprint(v)} + } +} + +// IsDryRun reports whether the PIKA_DRYRUN environment variable is set. +func IsDryRun() bool { + return os.Getenv("PIKA_DRYRUN") != "" +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000000000000000000000000000000000000..cc3b65af4d09de1736bc0347cbd2d8f72d518b22 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,273 @@ +package config + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestResolve(t *testing.T) { + tmpDir := t.TempDir() + homeDir := filepath.Join(tmpDir, "home") + if err := os.MkdirAll(homeDir, 0o755); err != nil { + t.Fatalf("creating temp HOME: %v", err) + } + + configPath := filepath.Join(tmpDir, "config.toml") + if err := os.WriteFile(configPath, []byte(resolveFixtureTOML), 0o600); err != nil { + t.Fatalf("writing fixture config: %v", err) + } + + t.Setenv("HOME", homeDir) + t.Setenv("PIKA_CONFIG_FILE", configPath) + t.Setenv("PIKA_CONFIG_PATHS", "") + + tests := []struct { + name string + preset string + command string + overrides map[string][]string + wantCommand string + wantWorkdir string + wantArgs []string + wantSections []string + wantEnviron map[string]string + wantFlags map[string][]string + }{ + { + name: "global command only", + preset: "", + command: "backup", + wantCommand: "backup", + wantWorkdir: "", + wantArgs: nil, + wantSections: []string{"global", "global.backup"}, + wantEnviron: map[string]string{ + "RESTIC_REPOSITORY": "/repos/global", + "RESTIC_PASSWORD_COMMAND": "pass global", + }, + wantFlags: map[string][]string{ + "--password-file": {"/secrets/global"}, + "--verbose": {""}, + "--tag": {"global-one", "global-two"}, + "--exclude-file": {"/etc/restic/global-excludes"}, + "--exclude-if-present": {".nobackup"}, + "--cache-dir": {"/srv/cache"}, + }, + }, + { + name: "preset command merge with special keys", + preset: "home", + command: "backup", + wantCommand: "backup", + wantWorkdir: filepath.Join(homeDir, "work", "home"), + wantArgs: []string{"/home/alice", "/home/shared"}, + wantSections: []string{"global", "global.backup", "home", "home.backup"}, + wantEnviron: map[string]string{ + "RESTIC_REPOSITORY": "/repos/global", + "RESTIC_PASSWORD_COMMAND": "pass home", + }, + wantFlags: map[string][]string{ + "--password-file": {"/secrets/home"}, + "--verbose": {""}, + "--tag": {"home-tag"}, + "--exclude-file": {"/etc/restic/global-excludes"}, + "--exclude-if-present": {".nobackup"}, + "--cache-dir": {"/srv/cache"}, + "--repo": {"/repos/home"}, + }, + }, + { + name: "split preset sections", + preset: "home@cloud", + command: "backup", + wantCommand: "backup", + wantWorkdir: "", + wantArgs: []string{"/data/cloud"}, + wantSections: []string{"global", "global.backup", "@cloud", "home@", "home@.backup", "home@cloud", "home@cloud.backup"}, + wantEnviron: map[string]string{ + "RESTIC_REPOSITORY": "/repos/cloud", + "RESTIC_PASSWORD_COMMAND": "pass cloud backup", + }, + wantFlags: map[string][]string{ + "--password-file": {"/secrets/global"}, + "--verbose": {""}, + "--tag": {"cloud-tag"}, + "--exclude-file": {"/etc/restic/home-split-excludes"}, + "--exclude-if-present": {".nobackup"}, + "--cache-dir": {"/srv/cache"}, + "--repo": {"/repos/home-cloud"}, + }, + }, + { + name: "command alias", + preset: "archive", + command: "backup", + wantCommand: "snapshots", + wantWorkdir: "", + wantArgs: []string{"latest"}, + wantSections: []string{"global", "global.backup", "archive", "archive.backup"}, + wantEnviron: map[string]string{ + "RESTIC_REPOSITORY": "/repos/global", + "RESTIC_PASSWORD_COMMAND": "pass global", + }, + wantFlags: map[string][]string{ + "--password-file": {"/secrets/global"}, + "--verbose": {""}, + "--tag": {"global-one", "global-two"}, + "--exclude-file": {"/etc/restic/global-excludes"}, + "--exclude-if-present": {".nobackup"}, + "--cache-dir": {"/srv/cache"}, + "--json": {""}, + }, + }, + { + name: "cli overrides take precedence", + preset: "home", + command: "backup", + overrides: map[string][]string{ + "tag": {"cli-a", "cli-b"}, + "password-file": {"/secrets/cli"}, + "repo": {"/repos/cli"}, + "json": nil, + "_arguments": {"/cli/path"}, + "_workdir": {"~/work/cli"}, + "_command": {"backup"}, + "exclude-if-present": {".override-marker"}, + }, + wantCommand: "backup", + wantWorkdir: filepath.Join(homeDir, "work", "cli"), + wantArgs: []string{"/cli/path"}, + wantSections: []string{"global", "global.backup", "home", "home.backup"}, + wantEnviron: map[string]string{ + "RESTIC_REPOSITORY": "/repos/global", + "RESTIC_PASSWORD_COMMAND": "pass home", + }, + wantFlags: map[string][]string{ + "--password-file": {"/secrets/cli"}, + "--verbose": {""}, + "--tag": {"cli-a", "cli-b"}, + "--exclude-file": {"/etc/restic/global-excludes"}, + "--exclude-if-present": {".override-marker"}, + "--cache-dir": {"/srv/cache"}, + "--repo": {"/repos/cli"}, + "--json": {""}, + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + cfg, err := Resolve(tt.preset, tt.command, tt.overrides) + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + + if cfg.Command != tt.wantCommand { + t.Fatalf("command mismatch: got %q, want %q", cfg.Command, tt.wantCommand) + } + if cfg.Workdir != tt.wantWorkdir { + t.Fatalf("workdir mismatch: got %q, want %q", cfg.Workdir, tt.wantWorkdir) + } + if !equalStrings(cfg.Arguments, tt.wantArgs) { + t.Fatalf("arguments mismatch: got %#v, want %#v", cfg.Arguments, tt.wantArgs) + } + if !reflect.DeepEqual(cfg.SectionsRead, tt.wantSections) { + t.Fatalf("sections mismatch: got %#v, want %#v", cfg.SectionsRead, tt.wantSections) + } + if !reflect.DeepEqual(cfg.Environ, tt.wantEnviron) { + t.Fatalf("environ mismatch: got %#v, want %#v", cfg.Environ, tt.wantEnviron) + } + + for _, f := range cfg.Flags { + if !strings.HasPrefix(f.Name, "-") { + t.Fatalf("flag name %q is missing CLI prefix", f.Name) + } + } + + gotFlags := collectFlags(cfg.Flags) + if !reflect.DeepEqual(gotFlags, tt.wantFlags) { + t.Fatalf("flags mismatch: got %#v, want %#v", gotFlags, tt.wantFlags) + } + }) + } +} + +func collectFlags(flags []Flag) map[string][]string { + out := make(map[string][]string) + for _, flag := range flags { + out[flag.Name] = append(out[flag.Name], flag.Value) + } + return out +} + +func equalStrings(a, b []string) bool { + if len(a) == 0 && len(b) == 0 { + return true + } + return reflect.DeepEqual(a, b) +} + +const resolveFixtureTOML = ` +[vars] +cache-root = "/srv" + +[global] +password-file = "/secrets/global" +verbose = true +tag = ["global-one", "global-two"] + +[global.backup] +exclude-file = "/etc/restic/global-excludes" +exclude-if-present = ".nobackup" +cache-dir = "${vars.cache-root}/cache" + +[global.backup.environ] +RESTIC_REPOSITORY = "/repos/global" +RESTIC_PASSWORD_COMMAND = "pass global" + +[home] +repo = "/repos/home" +_workdir = "~/work/home" +tag = ["home-tag"] + +[home.backup] +_arguments = ["/home/alice", "/home/shared"] +password-file = "/secrets/home" + +[home.backup.environ] +RESTIC_PASSWORD_COMMAND = "pass home" + +["@cloud"] +repo = "/repos/cloud-base" +tag = ["cloud-tag"] + +["@cloud".environ] +RESTIC_REPOSITORY = "/repos/cloud" + +["home@"] +repo = "/repos/home-prefix" + +["home@".backup] +exclude-file = "/etc/restic/home-split-excludes" + +["home@".forget] +keep-last = 7 + +["home@cloud"] +repo = "/repos/home-cloud" + +["home@cloud".backup] +_arguments = ["/data/cloud"] + +["home@cloud".backup.environ] +RESTIC_PASSWORD_COMMAND = "pass cloud backup" + +[archive.backup] +_command = "snapshots" +_arguments = ["latest"] +json = true +` diff --git a/internal/config/files.go b/internal/config/files.go new file mode 100644 index 0000000000000000000000000000000000000000..20bbd3c30c7af7ad6616da302afc21421cd5bd8a --- /dev/null +++ b/internal/config/files.go @@ -0,0 +1,78 @@ +package config + +import ( + "os" + "path/filepath" + "sort" + "strings" +) + +// DefaultConfigDirs lists the base directories searched for config files, +// in ascending priority order. +var DefaultConfigDirs = []string{ + "/usr/share/pika", + "/etc/pika", + "~/.config/pika", +} + +// DiscoverFiles returns config file paths in ascending priority order. +// +// The search order is: +// 1. For each directory in DefaultConfigDirs: config.toml, then sorted conf.d/*.toml +// 2. Paths/globs from the PIKA_CONFIG_PATHS env var (colon-separated) +// 3. If PIKA_CONFIG_FILE is set, it replaces ALL of the above +// +// All paths support ~ (home dir) and $VAR expansion. +func DiscoverFiles() []string { + return discoverFiles(os.Getenv) +} + +// discoverFiles is the testable core; getenv abstracts os.Getenv. +func discoverFiles(getenv func(string) string) []string { + if single := getenv("PIKA_CONFIG_FILE"); single != "" { + return []string{ExpandPath(single)} + } + + var paths []string + + for _, dir := range DefaultConfigDirs { + dir = ExpandPath(dir) + + paths = append(paths, filepath.Join(dir, "config.toml")) + paths = append(paths, sortedGlob(filepath.Join(dir, "conf.d", "*.toml"))...) + } + + if extra := getenv("PIKA_CONFIG_PATHS"); extra != "" { + for _, entry := range strings.Split(extra, ":") { + entry = ExpandPath(strings.TrimSpace(entry)) + if strings.ContainsAny(entry, "*?[") { + paths = append(paths, sortedGlob(entry)...) + } else { + paths = append(paths, entry) + } + } + } + + return paths +} + +// ExpandPath expands ~ to the user's home directory and $VAR references. +func ExpandPath(p string) string { + if strings.HasPrefix(p, "~/") || p == "~" { + home, err := os.UserHomeDir() + if err == nil { + p = home + p[1:] + } + } + return os.ExpandEnv(p) +} + +// sortedGlob returns glob matches in sorted order, or nil on error. +func sortedGlob(pattern string) []string { + matches, err := filepath.Glob(pattern) + if err != nil || len(matches) == 0 { + return nil + } + sort.Strings(matches) + return matches +} diff --git a/internal/config/files_test.go b/internal/config/files_test.go new file mode 100644 index 0000000000000000000000000000000000000000..fddb5a9a64e487e4cf60bd7a8afb15fbaea5ff74 --- /dev/null +++ b/internal/config/files_test.go @@ -0,0 +1,87 @@ +package config + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestDiscoverFiles_ConfigFileOverride(t *testing.T) { + tmpDir := t.TempDir() + homeDir := filepath.Join(tmpDir, "home") + if err := os.MkdirAll(homeDir, 0o755); err != nil { + t.Fatalf("creating temp HOME: %v", err) + } + + t.Setenv("HOME", homeDir) + t.Setenv("PIKA_CONFIG_FILE", "~/only.toml") + t.Setenv("PIKA_CONFIG_PATHS", filepath.Join(tmpDir, "extras", "*.toml")) + + got := DiscoverFiles() + want := []string{filepath.Join(homeDir, "only.toml")} + + if !reflect.DeepEqual(got, want) { + t.Fatalf("DiscoverFiles() mismatch: got %#v, want %#v", got, want) + } +} + +func TestDiscoverFiles_Order(t *testing.T) { + tmpDir := t.TempDir() + dirA := filepath.Join(tmpDir, "a") + dirB := filepath.Join(tmpDir, "b") + extraDir := filepath.Join(tmpDir, "extra") + if err := os.MkdirAll(filepath.Join(dirA, "conf.d"), 0o755); err != nil { + t.Fatalf("mkdir dirA conf.d: %v", err) + } + if err := os.MkdirAll(filepath.Join(dirB, "conf.d"), 0o755); err != nil { + t.Fatalf("mkdir dirB conf.d: %v", err) + } + if err := os.MkdirAll(extraDir, 0o755); err != nil { + t.Fatalf("mkdir extraDir: %v", err) + } + + for _, path := range []string{ + filepath.Join(dirA, "conf.d", "z.toml"), + filepath.Join(dirA, "conf.d", "a.toml"), + filepath.Join(dirB, "conf.d", "c.toml"), + filepath.Join(dirB, "conf.d", "b.toml"), + filepath.Join(extraDir, "later.toml"), + filepath.Join(extraDir, "earlier.toml"), + } { + if err := os.WriteFile(path, []byte("# fixture"), 0o600); err != nil { + t.Fatalf("writing %s: %v", path, err) + } + } + + explicit := filepath.Join(tmpDir, "explicit.toml") + if err := os.WriteFile(explicit, []byte("# explicit"), 0o600); err != nil { + t.Fatalf("writing explicit file: %v", err) + } + + originalDirs := DefaultConfigDirs + DefaultConfigDirs = []string{dirA, dirB} + t.Cleanup(func() { + DefaultConfigDirs = originalDirs + }) + + t.Setenv("PIKA_CONFIG_FILE", "") + t.Setenv("PIKA_CONFIG_PATHS", filepath.Join(extraDir, "*.toml")+":"+explicit) + + got := DiscoverFiles() + want := []string{ + filepath.Join(dirA, "config.toml"), + filepath.Join(dirA, "conf.d", "a.toml"), + filepath.Join(dirA, "conf.d", "z.toml"), + filepath.Join(dirB, "config.toml"), + filepath.Join(dirB, "conf.d", "b.toml"), + filepath.Join(dirB, "conf.d", "c.toml"), + filepath.Join(extraDir, "earlier.toml"), + filepath.Join(extraDir, "later.toml"), + explicit, + } + + if !reflect.DeepEqual(got, want) { + t.Fatalf("DiscoverFiles() ordering mismatch: got %#v, want %#v", got, want) + } +} diff --git a/internal/config/presets.go b/internal/config/presets.go new file mode 100644 index 0000000000000000000000000000000000000000..142b87cf39999c0dfdc19703029bd7e7c48c8501 --- /dev/null +++ b/internal/config/presets.go @@ -0,0 +1,31 @@ +package config + +import ( + "sort" +) + +// Presets loads all discovered config files and returns the distinct preset +// names found as top-level TOML sections. The "global" section is excluded +// since it provides shared defaults, not a user-selectable preset. +func Presets() []string { + files := DiscoverFiles() + return presetsFrom(files) +} + +// presetsFrom is the testable core of Presets. +func presetsFrom(files []string) []string { + raw, err := loadFiles(files) + if err != nil { + return nil + } + + presets := make([]string, 0, len(raw)) + for key := range raw { + if key == "global" { + continue + } + presets = append(presets, key) + } + sort.Strings(presets) + return presets +} diff --git a/internal/config/presets_test.go b/internal/config/presets_test.go new file mode 100644 index 0000000000000000000000000000000000000000..89b15e715e05aa588f9adf02786f0227eab0820a --- /dev/null +++ b/internal/config/presets_test.go @@ -0,0 +1,83 @@ +package config + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +const presetsFixtureTOML = ` +[global] +verbose = true + +[global.backup] +exclude-file = "/etc/excludes" + +[home] +repo = "/repos/home" + +[home.backup] +_arguments = ["/home/alice"] + +["@cloud"] +repo = "/repos/cloud" + +["@cloud".environ] +AWS_PROFILE = "restic" + +["home@"] +host = "laptop" + +["home@cloud"] +repo = "/repos/home-cloud" + +[archive] +json = true + +[vars] +cache-root = "/srv" +` + +func TestPresetsFrom(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + if err := os.WriteFile(path, []byte(presetsFixtureTOML), 0o600); err != nil { + t.Fatalf("writing fixture: %v", err) + } + + got := presetsFrom([]string{path}) + + // We expect every top-level section except "global", sorted. + want := []string{"@cloud", "archive", "home", "home@", "home@cloud", "vars"} + + if !reflect.DeepEqual(got, want) { + t.Fatalf("presets mismatch:\n got: %#v\n want: %#v", got, want) + } +} + +func TestPresetsFromEmpty(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + if err := os.WriteFile(path, []byte("[global]\nverbose = true\n"), 0o600); err != nil { + t.Fatalf("writing fixture: %v", err) + } + + got := presetsFrom([]string{path}) + if len(got) != 0 { + t.Fatalf("expected no presets, got %#v", got) + } +} + +func TestPresetsFromMissing(t *testing.T) { + t.Parallel() + + got := presetsFrom([]string{"/nonexistent/path.toml"}) + if len(got) != 0 { + t.Fatalf("expected nil for missing file, got %#v", got) + } +} diff --git a/internal/menu/menu.go b/internal/menu/menu.go new file mode 100644 index 0000000000000000000000000000000000000000..b8d56d6ef0c6cc874ecad56e1973139adf968283 --- /dev/null +++ b/internal/menu/menu.go @@ -0,0 +1,177 @@ +package menu + +import ( + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +// Item represents a single menu entry. +type Item struct { + // Label is the full display text (e.g. "backup"). + Label string + // Hotkey is the single character that instantly selects this item (e.g. 'b'). + Hotkey rune + // Value is the string returned by Choice() when this item is selected. + // If empty, Label is used. + Value string +} + +// Model is a hand-rolled BubbleTea v2 model for an interactive hotkey menu. +type Model struct { + items []Item + cursor int + choice string + quitting bool + + hasDarkBG bool + lightDark lipgloss.LightDarkFunc +} + +// New creates a menu Model with the given items. +func New(items []Item) Model { + return Model{ + items: items, + hasDarkBG: true, // sensible default until we hear from the terminal + lightDark: lipgloss.LightDark(true), + } +} + +// Choice returns the selected item's value, or "" if nothing was chosen. +func (m Model) Choice() string { + return m.choice +} + +// Init requests the terminal background color so we can adapt styling. +func (m Model) Init() tea.Cmd { + return tea.RequestBackgroundColor +} + +// Update handles key presses and background color detection. +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.BackgroundColorMsg: + m.hasDarkBG = msg.IsDark() + m.lightDark = lipgloss.LightDark(m.hasDarkBG) + return m, nil + + case tea.KeyPressMsg: + switch msg.String() { + case "ctrl+c", "q": + m.quitting = true + return m, tea.Quit + + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + return m, nil + + case "down", "j": + if m.cursor < len(m.items)-1 { + m.cursor++ + } + return m, nil + + case "enter": + m.choice = m.itemValue(m.cursor) + return m, tea.Quit + + default: + // Check if the keypress matches any item's hotkey. + if len(msg.Text) == 1 { + r := rune(msg.Text[0]) + for i, item := range m.items { + if item.Hotkey == r { + m.cursor = i + m.choice = m.itemValue(i) + return m, tea.Quit + } + } + } + } + } + + return m, nil +} + +// View renders the menu as an inline vertical list. +func (m Model) View() tea.View { + if m.quitting || m.choice != "" { + return tea.NewView("") + } + + accentColor := m.lightDark( + lipgloss.Color("#7D56F4"), + lipgloss.Color("#AD8AFF"), + ) + normalColor := m.lightDark( + lipgloss.Color("#333333"), + lipgloss.Color("#DDDDDD"), + ) + cursorColor := m.lightDark( + lipgloss.Color("#7D56F4"), + lipgloss.Color("#AD8AFF"), + ) + + hotStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(accentColor) + labelStyle := lipgloss.NewStyle(). + Foreground(normalColor) + cursorStyle := lipgloss.NewStyle(). + Foreground(cursorColor). + Bold(true) + + var b strings.Builder + for i, item := range m.items { + cursor := " " + if i == m.cursor { + cursor = cursorStyle.Render("▸ ") + } + + line := renderItem(item, hotStyle, labelStyle) + fmt.Fprintf(&b, "%s%s\n", cursor, line) + } + + b.WriteString("\n") + b.WriteString(labelStyle.Render("↑/↓ navigate • hotkey or enter to select • q to quit")) + b.WriteString("\n") + + return tea.NewView(b.String()) +} + +// renderItem formats a single menu item with the hotkey character styled +// differently from the rest of the label. For example, with hotkey 'b' and +// label "backup", it renders "[b]ackup" where [b] is in the accent style. +func renderItem(item Item, hotStyle, labelStyle lipgloss.Style) string { + label := item.Label + hk := string(item.Hotkey) + idx := strings.Index(strings.ToLower(label), strings.ToLower(hk)) + + if idx < 0 { + // Hotkey not in label — show it as a prefix. + return hotStyle.Render("["+hk+"]") + " " + labelStyle.Render(label) + } + + before := label[:idx] + match := label[idx : idx+len(hk)] + after := label[idx+len(hk):] + + return labelStyle.Render(before) + + hotStyle.Render("["+match+"]") + + labelStyle.Render(after) +} + +// itemValue returns the value for the item at index i. +func (m Model) itemValue(i int) string { + if i < 0 || i >= len(m.items) { + return "" + } + if m.items[i].Value != "" { + return m.items[i].Value + } + return m.items[i].Label +} diff --git a/internal/restic/exec.go b/internal/restic/exec.go new file mode 100644 index 0000000000000000000000000000000000000000..0ad31617ec395ac2eab6e288988f8bfca3d2265d --- /dev/null +++ b/internal/restic/exec.go @@ -0,0 +1,97 @@ +package restic + +import ( + "fmt" + "os" + "os/exec" + "strings" + "syscall" + + "git.secluded.site/pika/internal/config" +) + +// DefaultExecutable is the restic binary name used when PIKA_EXECUTABLE is +// unset. +const DefaultExecutable = "restic" + +// Run replaces the current process with restic, configured according to cfg. +func Run(cfg *config.ResolvedConfig) error { + exe := executable() + + path, err := exec.LookPath(exe) + if err != nil { + return fmt.Errorf("finding %s: %w", exe, err) + } + + if cfg.Workdir != "" { + if err := os.Chdir(cfg.Workdir); err != nil { + return fmt.Errorf("chdir %s: %w", cfg.Workdir, err) + } + } + + argv := buildArgv(exe, cfg) + env := buildEnv(cfg.Environ) + + return syscall.Exec(path, argv, env) +} + +// DryRun formats a human-readable summary of what Run would execute. +func DryRun(cfg *config.ResolvedConfig) string { + var b strings.Builder + + if len(cfg.SectionsRead) > 0 { + fmt.Fprintf(&b, "config sections: %s\n", strings.Join(cfg.SectionsRead, " → ")) + } + + if cfg.Workdir != "" { + fmt.Fprintf(&b, "workdir: %s\n", cfg.Workdir) + } + + if len(cfg.Environ) > 0 { + fmt.Fprintln(&b, "environ:") + for k, v := range cfg.Environ { + fmt.Fprintf(&b, " %s=%s\n", k, v) + } + } + + argv := buildArgv(executable(), cfg) + fmt.Fprintf(&b, "command: %s\n", strings.Join(argv, " ")) + + return b.String() +} + +// executable returns the restic binary name, respecting PIKA_EXECUTABLE. +func executable() string { + if e := os.Getenv("PIKA_EXECUTABLE"); e != "" { + return config.ExpandPath(e) + } + return DefaultExecutable +} + +// buildArgv assembles the full argument vector for the restic process. +func buildArgv(exe string, cfg *config.ResolvedConfig) []string { + argv := []string{exe} + + if cfg.Command != "" { + argv = append(argv, cfg.Command) + } + + for _, f := range cfg.Flags { + argv = append(argv, f.Name) + if f.Value != "" { + argv = append(argv, f.Value) + } + } + + argv = append(argv, cfg.Arguments...) + return argv +} + +// buildEnv merges extra environment variables into the current process env. +func buildEnv(extra map[string]string) []string { + env := os.Environ() + for k, v := range extra { + env = append(env, k+"="+v) + } + return env +} diff --git a/internal/restic/exec_test.go b/internal/restic/exec_test.go new file mode 100644 index 0000000000000000000000000000000000000000..08cb54effffc45d31767936f64afef0b5235fb10 --- /dev/null +++ b/internal/restic/exec_test.go @@ -0,0 +1,132 @@ +package restic + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "git.secluded.site/pika/internal/config" +) + +func TestBuildArgv(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + exe string + cfg *config.ResolvedConfig + want []string + }{ + { + name: "command flags and args", + exe: "restic", + cfg: &config.ResolvedConfig{ + Command: "backup", + Flags: []config.Flag{ + {Name: "--repo", Value: "/repo"}, + {Name: "--json"}, + {Name: "-o", Value: "s3.connections=5"}, + }, + Arguments: []string{"/home/alice", "/var/lib"}, + }, + want: []string{"restic", "backup", "--repo", "/repo", "--json", "-o", "s3.connections=5", "/home/alice", "/var/lib"}, + }, + { + name: "no command still includes executable", + exe: "restic", + cfg: &config.ResolvedConfig{ + Flags: []config.Flag{{Name: "--json"}}, + }, + want: []string{"restic", "--json"}, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := buildArgv(tt.exe, tt.cfg) + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("buildArgv() mismatch: got %#v, want %#v", got, tt.want) + } + }) + } +} + +func TestBuildEnv(t *testing.T) { + t.Parallel() + + const key = "PIKA_TEST_BUILD_ENV" + const val = "set-by-test" + + env := buildEnv(map[string]string{key: val}) + + if !containsEntry(env, key+"="+val) { + t.Fatalf("buildEnv() missing %s=%s in %#v", key, val, env) + } +} + +func TestDryRunOutput(t *testing.T) { + t.Parallel() + + cfg := &config.ResolvedConfig{ + Command: "backup", + SectionsRead: []string{"global", "global.backup", "home", "home.backup"}, + Workdir: "/tmp/work", + Environ: map[string]string{ + "RESTIC_REPOSITORY": "/repos/home", + "RESTIC_PASSWORD": "secret", + }, + Flags: []config.Flag{ + {Name: "--repo", Value: "/repos/home"}, + {Name: "--json"}, + }, + Arguments: []string{"/home/alice"}, + } + + output := DryRun(cfg) + + for _, fragment := range []string{ + "config sections: global → global.backup → home → home.backup", + "workdir: /tmp/work", + "environ:", + "RESTIC_REPOSITORY=/repos/home", + "RESTIC_PASSWORD=secret", + "command: restic backup --repo /repos/home --json /home/alice", + } { + if !strings.Contains(output, fragment) { + t.Fatalf("DryRun() missing fragment %q in output:\n%s", fragment, output) + } + } +} + +func TestDryRunExecutableOverride(t *testing.T) { + tmpDir := t.TempDir() + homeDir := filepath.Join(tmpDir, "home") + if err := os.MkdirAll(homeDir, 0o755); err != nil { + t.Fatalf("creating temp HOME: %v", err) + } + + t.Setenv("HOME", homeDir) + t.Setenv("PIKA_EXECUTABLE", "~/bin/restic-alt") + + cfg := &config.ResolvedConfig{Command: "snapshots"} + output := DryRun(cfg) + + expectedPrefix := "command: " + filepath.Join(homeDir, "bin", "restic-alt") + " snapshots" + if !strings.Contains(output, expectedPrefix) { + t.Fatalf("DryRun() command mismatch: want fragment %q in output %q", expectedPrefix, output) + } +} + +func containsEntry(values []string, needle string) bool { + for _, value := range values { + if value == needle { + return true + } + } + return false +} diff --git a/main.go b/main.go new file mode 100644 index 0000000000000000000000000000000000000000..d0b80328f341696689732bc74a9c8b4c1754f69a --- /dev/null +++ b/main.go @@ -0,0 +1,7 @@ +package main + +import "git.secluded.site/pika/cmd" + +func main() { + cmd.Execute() +} diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000000000000000000000000000000000000..5aa9dd3a81a0cab20e7538bbf13d5af23d1c03b7 --- /dev/null +++ b/mise.toml @@ -0,0 +1,39 @@ +[tools] +go = "latest" +"go:golang.org/x/vuln/cmd/govulncheck" = "latest" +"go:mvdan.cc/gofumpt" = "latest" +golangci-lint = "latest" + +[tasks.build] +run = "go build -o pika ." + +[tasks.install] +run = "go install ." + +[tasks.test] +run = "go test -v ./..." + +[tasks.fmt] +run = "gofumpt -w ." + +[tasks."fmt:check"] +run = """ +output=$(gofumpt -d .) +if [ -n "$output" ]; then + echo "$output" + echo "Files unformatted; execute 'mise run fmt'" + exit 1 +fi +""" + +[tasks.lint] +run = "golangci-lint run" + +[tasks.vuln] +run = "govulncheck ./..." + +[tasks.vet] +run = "go vet ./..." + +[tasks.check] +depends = ["fmt:check", "vet", "lint", "vuln", "build", "test"] diff --git a/restic-cli-catalogue.md b/restic-cli-catalogue.md new file mode 100644 index 0000000000000000000000000000000000000000..12a6a475d676f7f3021ef0bb9d400b2148e5cfbd --- /dev/null +++ b/restic-cli-catalogue.md @@ -0,0 +1,1055 @@ +# Restic CLI Catalogue + +**Version:** 0.18.1 (compiled with go1.25.6 on linux/amd64) + +Generated from `restic --help` and `restic --help` output on 2026-03-13. + +--- + +## Table of Contents + +- [Global Flags](#global-flags) +- [Commands](#commands) + - [backup](#backup) + - [cache](#cache) + - [cat](#cat) + - [check](#check) + - [copy](#copy) + - [diff](#diff) + - [dump](#dump) + - [find](#find) + - [forget](#forget) + - [init](#init) + - [key](#key) + - [key add](#key-add) + - [key list](#key-list) + - [key passwd](#key-passwd) + - [key remove](#key-remove) + - [list](#list) + - [ls](#ls) + - [migrate](#migrate) + - [mount](#mount) + - [prune](#prune) + - [recover](#recover) + - [repair](#repair) + - [repair index](#repair-index) + - [repair packs](#repair-packs) + - [repair snapshots](#repair-snapshots) + - [restore](#restore) + - [rewrite](#rewrite) + - [snapshots](#snapshots) + - [stats](#stats) + - [tag](#tag) + - [unlock](#unlock) +- [Advanced / Additional Commands](#advanced--additional-commands) + - [features](#features) + - [options](#options) + - [generate](#generate) + - [version](#version) + +--- + +## Global Flags + +These flags apply to **all** commands. They are inherited by every subcommand. None are strictly required — the repository and password can be supplied via environment variables instead of flags. + +| Long | Short | Type | Default | Description | +|------|-------|------|---------|-------------| +| `--cacert` | | file (string) | system certificates or `$RESTIC_CACERT` | File to load root certificates from | +| `--cache-dir` | | directory (string) | system default cache directory | Set the cache directory | +| `--cleanup-cache` | | bool | `false` | Auto remove old cache directories | +| `--compression` | | mode (string) | `auto` (or `$RESTIC_COMPRESSION`) | Compression mode (only for repo format v2), one of `auto\|off\|max` | +| `--help` | `-h` | bool | `false` | Help for restic | +| `--http-user-agent` | | string | `""` | Set a http user agent for outgoing http requests | +| `--insecure-no-password` | | bool | `false` | Use an empty password for the repository (insecure) | +| `--insecure-tls` | | bool | `false` | Skip TLS certificate verification when connecting to the repository (insecure) | +| `--json` | | bool | `false` | Set output mode to JSON for commands that support it | +| `--key-hint` | | key (string) | `$RESTIC_KEY_HINT` | Key ID of key to try decrypting first | +| `--limit-download` | | rate (int, KiB/s) | unlimited | Limits downloads to a maximum rate in KiB/s | +| `--limit-upload` | | rate (int, KiB/s) | unlimited | Limits uploads to a maximum rate in KiB/s | +| `--no-cache` | | bool | `false` | Do not use a local cache | +| `--no-extra-verify` | | bool | `false` | Skip additional verification of data before upload (see documentation) | +| `--no-lock` | | bool | `false` | Do not lock the repository, this allows some operations on read-only repositories | +| `--option` | `-o` | key=value (string, repeatable) | — | Set extended option (can be specified multiple times) | +| `--pack-size` | | size (int, MiB) | `$RESTIC_PACK_SIZE` | Set target pack size in MiB, created pack files may be larger | +| `--password-command` | | command (string) | `$RESTIC_PASSWORD_COMMAND` | Shell command to obtain the repository password from | +| `--password-file` | `-p` | file (string) | `$RESTIC_PASSWORD_FILE` | File to read the repository password from | +| `--quiet` | `-q` | bool | `false` | Do not output comprehensive progress report | +| `--repo` | `-r` | repository (string) | `$RESTIC_REPOSITORY` | Repository to backup to or restore from | +| `--repository-file` | | file (string) | `$RESTIC_REPOSITORY_FILE` | File to read the repository location from | +| `--retry-lock` | | duration | no retries | Retry to lock the repository if it is already locked, takes a value like `5m` or `2h` | +| `--stuck-request-timeout` | | duration | `5m0s` | Duration after which to retry stuck requests | +| `--tls-client-cert` | | file (string) | `$RESTIC_TLS_CLIENT_CERT` | Path to a file containing PEM encoded TLS client certificate and private key | +| `--verbose` | `-v` | int (repeatable) | `0` | Be verbose (specify multiple times or a level using `--verbose=n`, max level/times is 2) | + +--- + +## Commands + +--- + +### backup + +**Description:** Create a new backup of files and/or directories. + +**Usage:** `restic backup [flags] [FILE/DIR] ...` + +**Exit codes:** 0 = success, 1 = fatal error (no snapshot created), 3 = some source data could not be read (incomplete snapshot created), 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password. + +#### Positional Arguments + +| Name | Required | Description | +|------|----------|-------------| +| `FILE/DIR ...` | optional (but at least one source is needed — can come from `--files-from*` or `--stdin` instead) | Files and/or directories to back up | + +#### Command-Specific Flags + +| Long | Short | Type | Default | Required | Description | +|------|-------|------|---------|----------|-------------| +| `--dry-run` | `-n` | bool | `false` | optional | Do not upload or write any data, just show what would be done | +| `--exclude` | `-e` | pattern (string, repeatable) | — | optional | Exclude a pattern (can be specified multiple times) | +| `--exclude-caches` | | bool | `false` | optional | Excludes cache directories that are marked with a CACHEDIR.TAG file | +| `--exclude-file` | | file (string, repeatable) | — | optional | Read exclude patterns from a file (can be specified multiple times) | +| `--exclude-if-present` | | filename[:header] (string, repeatable) | — | optional | Exclude contents of directories containing filename (except filename itself) if header matches (can be specified multiple times) | +| `--exclude-larger-than` | | size (string) | — | optional | Max size of the files to be backed up (allowed suffixes: k/K, m/M, g/G, t/T) | +| `--files-from` | | file (string, repeatable) | — | optional | Read the files to backup from file (can be combined with file args; can be specified multiple times) | +| `--files-from-raw` | | file (string, repeatable) | — | optional | Read the files to backup from file (can be combined with file args; can be specified multiple times) | +| `--files-from-verbatim` | | file (string, repeatable) | — | optional | Read the files to backup from file (can be combined with file args; can be specified multiple times) | +| `--force` | `-f` | bool | `false` | optional | Force re-reading the source files/directories (overrides the "parent" flag) | +| `--group-by` | `-g` | group (string) | `host,paths` | optional | Group snapshots by host, paths and/or tags, separated by comma (disable grouping with '') | +| `--help` | `-h` | bool | `false` | optional | Help for backup | +| `--host` | `-H` | hostname (string) | `$RESTIC_HOST` | optional | Set the hostname for the snapshot manually. To prevent an expensive rescan use the "parent" flag | +| `--iexclude` | | pattern (string, repeatable) | — | optional | Same as --exclude but ignores the casing of filenames | +| `--iexclude-file` | | file (string, repeatable) | — | optional | Same as --exclude-file but ignores casing of filenames in patterns | +| `--ignore-ctime` | | bool | `false` | optional | Ignore ctime changes when checking for modified files | +| `--ignore-inode` | | bool | `false` | optional | Ignore inode number and ctime changes when checking for modified files | +| `--no-scan` | | bool | `false` | optional | Do not run scanner to estimate size of backup | +| `--one-file-system` | `-x` | bool | `false` | optional | Exclude other file systems, don't cross filesystem boundaries and subvolumes | +| `--parent` | | snapshot (string) | latest snapshot in group | optional | Use this parent snapshot | +| `--read-concurrency` | | int | `$RESTIC_READ_CONCURRENCY` or `2` | optional | Read n files concurrently | +| `--skip-if-unchanged` | | bool | `false` | optional | Skip snapshot creation if identical to parent snapshot | +| `--stdin` | | bool | `false` | optional | Read backup from stdin | +| `--stdin-filename` | | filename (string) | `"stdin"` | optional | Filename to use when reading from stdin | +| `--stdin-from-command` | | bool | `false` | optional | Interpret arguments as command to execute and store its stdout | +| `--tag` | | tags (string-list, repeatable) | `[]` | optional | Add tags for the new snapshot in the format `tag[,tag,...]` (can be specified multiple times) | +| `--time` | | time (string) | now | optional | Time of the backup (ex. '2012-11-01 22:08:41') | +| `--with-atime` | | bool | `false` | optional | Store the atime for all files and directories | + +--- + +### cache + +**Description:** Operate on local cache directories. + +**Usage:** `restic cache [flags]` + +**Exit codes:** 0 = success, 1 = error. + +#### Positional Arguments + +None. + +#### Command-Specific Flags + +| Long | Short | Type | Default | Required | Description | +|------|-------|------|---------|----------|-------------| +| `--cleanup` | | bool | `false` | optional | Remove old cache directories | +| `--help` | `-h` | bool | `false` | optional | Help for cache | +| `--max-age` | | int (days) | `30` | optional | Max age in days for cache directories to be considered old | +| `--no-size` | | bool | `false` | optional | Do not output the size of the cache directories | + +--- + +### cat + +**Description:** Print internal objects to stdout. + +**Usage:** `restic cat [flags] [masterkey|config|pack ID|blob ID|snapshot ID|index ID|key ID|lock ID|tree snapshot:subfolder]` + +**Exit codes:** 0 = success, 1 = error, 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password. + +#### Positional Arguments + +| Name | Required | Description | +|------|----------|-------------| +| object type + ID | **required** | One of: `masterkey`, `config`, `pack ID`, `blob ID`, `snapshot ID`, `index ID`, `key ID`, `lock ID`, `tree snapshot:subfolder` | + +#### Command-Specific Flags + +| Long | Short | Type | Default | Required | Description | +|------|-------|------|---------|----------|-------------| +| `--help` | `-h` | bool | `false` | optional | Help for cat | + +--- + +### check + +**Description:** Test the repository for errors and report any errors found. Can also read all data to simulate a restore. + +**Usage:** `restic check [flags]` + +**Exit codes:** 0 = success, 1 = error, 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password. + +**Note:** By default, the check command will always load all data directly from the repository and not use a local cache. + +#### Positional Arguments + +None. + +#### Command-Specific Flags + +| Long | Short | Type | Default | Required | Description | +|------|-------|------|---------|----------|-------------| +| `--help` | `-h` | bool | `false` | optional | Help for check | +| `--read-data` | | bool | `false` | optional | Read all data blobs | +| `--read-data-subset` | | subset (string) | — | optional | Read a subset of data packs, specified as 'n/t' for specific part, or either 'x%' or 'x.y%' or a size in bytes with suffixes k/K, m/M, g/G, t/T for a random subset | +| `--with-cache` | | bool | `false` | optional | Use existing cache, only read uncached data from repository | + +--- + +### copy + +**Description:** Copy snapshots from one repository to another. + +**Usage:** `restic copy [flags] [snapshotID ...]` + +**Exit codes:** 0 = success, 1 = error, 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password. + +#### Positional Arguments + +| Name | Required | Description | +|------|----------|-------------| +| `snapshotID ...` | optional | Snapshot IDs to copy (if omitted, copies all matching snapshots) | + +#### Command-Specific Flags + +| Long | Short | Type | Default | Required | Description | +|------|-------|------|---------|----------|-------------| +| `--from-insecure-no-password` | | bool | `false` | optional | Use an empty password for the source repository (insecure) | +| `--from-key-hint` | | string | `$RESTIC_FROM_KEY_HINT` | optional | Key ID of key to try decrypting the source repository first | +| `--from-password-command` | | command (string) | `$RESTIC_FROM_PASSWORD_COMMAND` | optional | Shell command to obtain the source repository password from | +| `--from-password-file` | | file (string) | `$RESTIC_FROM_PASSWORD_FILE` | optional | File to read the source repository password from | +| `--from-repo` | | repository (string) | `$RESTIC_FROM_REPOSITORY` | **required** (or env) | Source repository to copy snapshots from | +| `--from-repository-file` | | file (string) | `$RESTIC_FROM_REPOSITORY_FILE` | optional | File from which to read the source repository location | +| `--help` | `-h` | bool | `false` | optional | Help for copy | +| `--host` | `-H` | host (string, repeatable) | `$RESTIC_HOST` | optional | Only consider snapshots for this host (can be specified multiple times) | +| `--path` | | path (string, repeatable) | — | optional | Only consider snapshots including this (absolute) path (can be specified multiple times) | +| `--tag` | | tag[,tag,...] (string-list, repeatable) | `[]` | optional | Only consider snapshots including tag[,tag,...] (can be specified multiple times) | + +--- + +### diff + +**Description:** Show differences between two snapshots. + +**Usage:** `restic diff [flags] snapshotID snapshotID` + +**Exit codes:** 0 = success, 1 = error, 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password. + +**Output key:** `+` = added, `-` = removed, `U` = metadata updated, `M` = content modified, `T` = type changed, `?` = bitrot detected. + +#### Positional Arguments + +| Name | Required | Description | +|------|----------|-------------| +| `snapshotID` (first) | **required** | First snapshot (supports `snapshotID:subfolder` syntax) | +| `snapshotID` (second) | **required** | Second snapshot (supports `snapshotID:subfolder` syntax) | + +#### Command-Specific Flags + +| Long | Short | Type | Default | Required | Description | +|------|-------|------|---------|----------|-------------| +| `--help` | `-h` | bool | `false` | optional | Help for diff | +| `--metadata` | | bool | `false` | optional | Print changes in metadata | + +--- + +### dump + +**Description:** Print a backed-up file to stdout. Folders are output as tar (default) or zip. + +**Usage:** `restic dump [flags] snapshotID file` + +**Exit codes:** 0 = success, 1 = error, 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password. + +#### Positional Arguments + +| Name | Required | Description | +|------|----------|-------------| +| `snapshotID` | **required** | Snapshot ID to dump from (special value `latest` supported; supports `snapshotID:subfolder` syntax) | +| `file` | **required** | File path within the snapshot to dump (pass `/` for whole snapshot as archive) | + +#### Command-Specific Flags + +| Long | Short | Type | Default | Required | Description | +|------|-------|------|---------|----------|-------------| +| `--archive` | `-a` | format (string) | `"tar"` | optional | Set archive format as "tar" or "zip" | +| `--help` | `-h` | bool | `false` | optional | Help for dump | +| `--host` | `-H` | host (string, repeatable) | `$RESTIC_HOST` | optional | Only consider snapshots for this host, when snapshot ID "latest" is given (can be specified multiple times) | +| `--path` | | path (string, repeatable) | — | optional | Only consider snapshots including this (absolute) path, when snapshot ID "latest" is given | +| `--tag` | | tag[,tag,...] (string-list, repeatable) | `[]` | optional | Only consider snapshots including tag[,tag,...], when snapshot ID "latest" is given | +| `--target` | `-t` | path (string) | — | optional | Write the output to target path | + +--- + +### find + +**Description:** Find a file, a directory or restic IDs. + +**Usage:** `restic find [flags] PATTERN...` + +**Exit codes:** 0 = success, 1 = error, 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password. + +#### Positional Arguments + +| Name | Required | Description | +|------|----------|-------------| +| `PATTERN ...` | **required** | One or more search patterns (file/dir names, or IDs when `--blob`/`--tree`/`--pack` is used) | + +#### Command-Specific Flags + +| Long | Short | Type | Default | Required | Description | +|------|-------|------|---------|----------|-------------| +| `--blob` | | bool | `false` | optional | Pattern is a blob-ID | +| `--help` | `-h` | bool | `false` | optional | Help for find | +| `--host` | `-H` | host (string, repeatable) | `$RESTIC_HOST` | optional | Only consider snapshots for this host (can be specified multiple times) | +| `--human-readable` | | bool | `false` | optional | Print sizes in human readable format | +| `--ignore-case` | `-i` | bool | `false` | optional | Ignore case for pattern | +| `--long` | `-l` | bool | `false` | optional | Use a long listing format showing size and mode | +| `--newest` | `-N` | string | — | optional | Newest modification date/time | +| `--oldest` | `-O` | string | — | optional | Oldest modification date/time | +| `--pack` | | bool | `false` | optional | Pattern is a pack-ID | +| `--path` | | path (string, repeatable) | — | optional | Only consider snapshots including this (absolute) path | +| `--reverse` | `-R` | bool | `false` | optional | Reverse sort order oldest to newest | +| `--show-pack-id` | | bool | `false` | optional | Display the pack-ID the blobs belong to (with --blob or --tree) | +| `--snapshot` | `-s` | id (string, repeatable) | — | optional | Snapshot id to search in (can be given multiple times) | +| `--tag` | | tag[,tag,...] (string-list, repeatable) | `[]` | optional | Only consider snapshots including tag[,tag,...] (can be specified multiple times) | +| `--tree` | | bool | `false` | optional | Pattern is a tree-ID | + +--- + +### forget + +**Description:** Remove snapshots from the repository according to a policy. + +**Usage:** `restic forget [flags] [snapshot ID] [...]` + +**Exit codes:** 0 = success, 1 = error, 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password. + +**Note:** This only deletes snapshot objects (references). Use `prune` to remove unreferenced data afterwards. + +#### Positional Arguments + +| Name | Required | Description | +|------|----------|-------------| +| `snapshot ID ...` | optional | Specific snapshot IDs to forget (if omitted, uses policy flags to select) | + +#### Command-Specific Flags + +| Long | Short | Type | Default | Required | Description | +|------|-------|------|---------|----------|-------------| +| `--compact` | `-c` | bool | `false` | optional | Use compact output format | +| `--dry-run` | `-n` | bool | `false` | optional | Do not delete anything, just print what would be done | +| `--group-by` | `-g` | group (string) | `host,paths` | optional | Group snapshots by host, paths and/or tags, separated by comma (disable grouping with '') | +| `--help` | `-h` | bool | `false` | optional | Help for forget | +| `--host` | | host (string, repeatable) | `$RESTIC_HOST` | optional | Only consider snapshots for this host (can be specified multiple times) | +| `--keep-daily` | `-d` | int or `unlimited` | — | optional | Keep the last n daily snapshots | +| `--keep-hourly` | `-H` | int or `unlimited` | — | optional | Keep the last n hourly snapshots | +| `--keep-last` | `-l` | int or `unlimited` | — | optional | Keep the last n snapshots | +| `--keep-monthly` | `-m` | int or `unlimited` | — | optional | Keep the last n monthly snapshots | +| `--keep-tag` | | taglist (string-list, repeatable) | `[]` | optional | Keep snapshots with this taglist (can be specified multiple times) | +| `--keep-weekly` | `-w` | int or `unlimited` | — | optional | Keep the last n weekly snapshots | +| `--keep-within` | | duration (string) | — | optional | Keep snapshots newer than duration (eg. 1y5m7d2h) relative to the latest snapshot | +| `--keep-within-daily` | | duration (string) | — | optional | Keep daily snapshots newer than duration relative to the latest snapshot | +| `--keep-within-hourly` | | duration (string) | — | optional | Keep hourly snapshots newer than duration relative to the latest snapshot | +| `--keep-within-monthly` | | duration (string) | — | optional | Keep monthly snapshots newer than duration relative to the latest snapshot | +| `--keep-within-weekly` | | duration (string) | — | optional | Keep weekly snapshots newer than duration relative to the latest snapshot | +| `--keep-within-yearly` | | duration (string) | — | optional | Keep yearly snapshots newer than duration relative to the latest snapshot | +| `--keep-yearly` | `-y` | int or `unlimited` | — | optional | Keep the last n yearly snapshots | +| `--max-repack-size` | | size (string) | — | optional | Stop after repacking this much data in total (allowed suffixes: k/K, m/M, g/G, t/T) | +| `--max-unused` | | limit (string) | `"5%"` | optional | Tolerate given limit of unused data (absolute value with suffixes, a value in %, or 'unlimited') | +| `--path` | | path (string, repeatable) | — | optional | Only consider snapshots including this (absolute) path | +| `--prune` | | bool | `false` | optional | Automatically run the 'prune' command if snapshots have been removed | +| `--repack-cacheable-only` | | bool | `false` | optional | Only repack packs which are cacheable | +| `--repack-small` | | bool | `false` | optional | Repack pack files below 80% of target pack size | +| `--repack-smaller-than` | | below-limit (string) | — | optional | Pack below-limit packfiles (allowed suffixes: k/K, m/M) | +| `--repack-uncompressed` | | bool | `false` | optional | Repack all uncompressed data | +| `--tag` | | tag[,tag,...] (string-list, repeatable) | `[]` | optional | Only consider snapshots including tag[,tag,...] (can be specified multiple times) | +| `--unsafe-allow-remove-all` | | bool | `false` | optional | Allow deleting all snapshots of a snapshot group | + +--- + +### init + +**Description:** Initialize a new repository. + +**Usage:** `restic init [flags]` + +**Exit codes:** 0 = success, 1 = error. + +#### Positional Arguments + +None. + +#### Command-Specific Flags + +| Long | Short | Type | Default | Required | Description | +|------|-------|------|---------|----------|-------------| +| `--copy-chunker-params` | | bool | `false` | optional | Copy chunker parameters from the secondary repository (useful with the copy command) | +| `--from-insecure-no-password` | | bool | `false` | optional | Use an empty password for the source repository (insecure) | +| `--from-key-hint` | | string | `$RESTIC_FROM_KEY_HINT` | optional | Key ID of key to try decrypting the source repository first | +| `--from-password-command` | | command (string) | `$RESTIC_FROM_PASSWORD_COMMAND` | optional | Shell command to obtain the source repository password from | +| `--from-password-file` | | file (string) | `$RESTIC_FROM_PASSWORD_FILE` | optional | File to read the source repository password from | +| `--from-repo` | | repository (string) | `$RESTIC_FROM_REPOSITORY` | optional | Source repository to copy chunker parameters from | +| `--from-repository-file` | | file (string) | `$RESTIC_FROM_REPOSITORY_FILE` | optional | File from which to read the source repository location | +| `--help` | `-h` | bool | `false` | optional | Help for init | +| `--repository-version` | | string | `"stable"` | optional | Repository format version to use, allowed values are a format version, 'latest' and 'stable' | + +--- + +### key + +**Description:** Manage keys (passwords). This is a parent command with subcommands. + +**Usage:** `restic key [command]` + +**Subcommands:** `add`, `list`, `passwd`, `remove` + +#### Command-Specific Flags + +| Long | Short | Type | Default | Required | Description | +|------|-------|------|---------|----------|-------------| +| `--help` | `-h` | bool | `false` | optional | Help for key | + +--- + +### key add + +**Description:** Add a new key (password) to the repository; returns the new key ID. + +**Usage:** `restic key add [flags]` + +**Exit codes:** 0 = success, 1 = error, 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password. + +#### Positional Arguments + +None. + +#### Command-Specific Flags + +| Long | Short | Type | Default | Required | Description | +|------|-------|------|---------|----------|-------------| +| `--help` | `-h` | bool | `false` | optional | Help for add | +| `--host` | | string | — | optional | The hostname for new key | +| `--new-insecure-no-password` | | bool | `false` | optional | Add an empty password for the repository (insecure) | +| `--new-password-file` | | file (string) | — | optional | File from which to read the new password | +| `--user` | | string | — | optional | The username for new key | + +--- + +### key list + +**Description:** List keys (passwords) associated with the repository. + +**Usage:** `restic key list [flags]` + +**Exit codes:** 0 = success, 1 = error, 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password. + +#### Positional Arguments + +None. + +#### Command-Specific Flags + +| Long | Short | Type | Default | Required | Description | +|------|-------|------|---------|----------|-------------| +| `--help` | `-h` | bool | `false` | optional | Help for list | + +--- + +### key passwd + +**Description:** Change key (password); creates a new key ID and removes the old key ID, returns new key ID. + +**Usage:** `restic key passwd [flags]` + +**Exit codes:** 0 = success, 1 = error, 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password. + +#### Positional Arguments + +None. + +#### Command-Specific Flags + +| Long | Short | Type | Default | Required | Description | +|------|-------|------|---------|----------|-------------| +| `--help` | `-h` | bool | `false` | optional | Help for passwd | +| `--host` | | string | — | optional | The hostname for new key | +| `--new-insecure-no-password` | | bool | `false` | optional | Add an empty password for the repository (insecure) | +| `--new-password-file` | | file (string) | — | optional | File from which to read the new password | +| `--user` | | string | — | optional | The username for new key | + +--- + +### key remove + +**Description:** Remove key ID (password) from the repository. Cannot remove the current key being used to access the repository. + +**Usage:** `restic key remove [ID] [flags]` + +**Exit codes:** 0 = success, 1 = error, 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password. + +#### Positional Arguments + +| Name | Required | Description | +|------|----------|-------------| +| `ID` | **required** | Key ID to remove | + +#### Command-Specific Flags + +| Long | Short | Type | Default | Required | Description | +|------|-------|------|---------|----------|-------------| +| `--help` | `-h` | bool | `false` | optional | Help for remove | + +--- + +### list + +**Description:** List objects in the repository based on type. + +**Usage:** `restic list [flags] [blobs|packs|index|snapshots|keys|locks]` + +**Exit codes:** 0 = success, 1 = error, 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password. + +#### Positional Arguments + +| Name | Required | Description | +|------|----------|-------------| +| object type | **required** | One of: `blobs`, `packs`, `index`, `snapshots`, `keys`, `locks` | + +#### Command-Specific Flags + +| Long | Short | Type | Default | Required | Description | +|------|-------|------|---------|----------|-------------| +| `--help` | `-h` | bool | `false` | optional | Help for list | + +--- + +### ls + +**Description:** List files in a snapshot. + +**Usage:** `restic ls [flags] snapshotID [dir...]` + +**Exit codes:** 0 = success, 1 = error, 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password. + +#### Positional Arguments + +| Name | Required | Description | +|------|----------|-------------| +| `snapshotID` | **required** | Snapshot ID to list (special value `latest` supported) | +| `dir ...` | optional | Absolute directory paths to filter listing (must start with `/`) | + +#### Command-Specific Flags + +| Long | Short | Type | Default | Required | Description | +|------|-------|------|---------|----------|-------------| +| `--help` | `-h` | bool | `false` | optional | Help for ls | +| `--host` | `-H` | host (string, repeatable) | `$RESTIC_HOST` | optional | Only consider snapshots for this host, when snapshot ID "latest" is given | +| `--human-readable` | | bool | `false` | optional | Print sizes in human readable format | +| `--long` | `-l` | bool | `false` | optional | Use a long listing format showing size and mode | +| `--ncdu` | | bool | `false` | optional | Output NCDU export format (pipe into 'ncdu -f -') | +| `--path` | | path (string, repeatable) | — | optional | Only consider snapshots including this (absolute) path, when snapshot ID "latest" is given | +| `--recursive` | | bool | `false` | optional | Include files in subfolders of the listed directories | +| `--reverse` | | bool | `false` | optional | Reverse sorted output | +| `--sort` | `-s` | mode (string) | `name` | optional | Sort output by (name\|size\|time=mtime\|atime\|ctime\|extension) | +| `--tag` | | tag[,tag,...] (string-list, repeatable) | `[]` | optional | Only consider snapshots including tag[,tag,...], when snapshot ID "latest" is given | + +--- + +### migrate + +**Description:** Apply migrations to a repository. Lists available migrations if none specified. + +**Usage:** `restic migrate [flags] [migration name] [...]` + +**Exit codes:** 0 = success, 1 = error, 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password. + +#### Positional Arguments + +| Name | Required | Description | +|------|----------|-------------| +| `migration name ...` | optional | Names of migrations to apply (if omitted, lists available migrations) | + +#### Command-Specific Flags + +| Long | Short | Type | Default | Required | Description | +|------|-------|------|---------|----------|-------------| +| `--force` | `-f` | bool | `false` | optional | Apply a migration a second time | +| `--help` | `-h` | bool | `false` | optional | Help for migrate | + +--- + +### mount + +**Description:** Mount the repository via FUSE to a directory (read-only). + +**Usage:** `restic mount [flags] mountpoint` + +**Exit codes:** 0 = success, 1 = error, 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password. + +#### Positional Arguments + +| Name | Required | Description | +|------|----------|-------------| +| `mountpoint` | **required** | Directory to mount the repository on | + +#### Command-Specific Flags + +| Long | Short | Type | Default | Required | Description | +|------|-------|------|---------|----------|-------------| +| `--allow-other` | | bool | `false` | optional | Allow other users to access the data in the mounted directory | +| `--help` | `-h` | bool | `false` | optional | Help for mount | +| `--host` | `-H` | host (string, repeatable) | `$RESTIC_HOST` | optional | Only consider snapshots for this host (can be specified multiple times) | +| `--no-default-permissions` | | bool | `false` | optional | For 'allow-other', ignore Unix permissions and allow users to read all snapshot files | +| `--owner-root` | | bool | `false` | optional | Use 'root' as the owner of files and dirs | +| `--path` | | path (string, repeatable) | — | optional | Only consider snapshots including this (absolute) path | +| `--path-template` | | template (string, repeatable) | see note | optional | Set template for path names (can be specified multiple times). Default templates: `ids/%i`, `snapshots/%T`, `hosts/%h/%T`, `tags/%t/%T`. Patterns: `%i` short ID, `%I` long ID, `%u` username, `%h` hostname, `%t` tags, `%T` timestamp | +| `--tag` | | tag[,tag,...] (string-list, repeatable) | `[]` | optional | Only consider snapshots including tag[,tag,...] | +| `--time-template` | | template (string) | `"2006-01-02T15:04:05Z07:00"` | optional | Set template to use for times (Go time format) | + +--- + +### prune + +**Description:** Remove unneeded data from the repository. + +**Usage:** `restic prune [flags]` + +**Exit codes:** 0 = success, 1 = error, 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password. + +#### Positional Arguments + +None. + +#### Command-Specific Flags + +| Long | Short | Type | Default | Required | Description | +|------|-------|------|---------|----------|-------------| +| `--dry-run` | `-n` | bool | `false` | optional | Do not modify the repository, just print what would be done | +| `--help` | `-h` | bool | `false` | optional | Help for prune | +| `--max-repack-size` | | size (string) | — | optional | Stop after repacking this much data in total (allowed suffixes: k/K, m/M, g/G, t/T) | +| `--max-unused` | | limit (string) | `"5%"` | optional | Tolerate given limit of unused data (absolute value with suffixes, a value in %, or 'unlimited') | +| `--repack-cacheable-only` | | bool | `false` | optional | Only repack packs which are cacheable | +| `--repack-small` | | bool | `false` | optional | Repack pack files below 80% of target pack size | +| `--repack-smaller-than` | | below-limit (string) | — | optional | Pack below-limit packfiles (allowed suffixes: k/K, m/M) | +| `--repack-uncompressed` | | bool | `false` | optional | Repack all uncompressed data | +| `--unsafe-recover-no-free-space` | | string | — | optional | UNSAFE, READ THE DOCUMENTATION BEFORE USING! Try to recover a repository stuck with no free space. Do not use without trying out 'prune --max-repack-size 0' first. | + +--- + +### recover + +**Description:** Recover data from the repository not referenced by snapshots. Builds a new snapshot from all directories found in the raw data that are not referenced in an existing snapshot. + +**Usage:** `restic recover [flags]` + +**Exit codes:** 0 = success, 1 = error, 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password. + +#### Positional Arguments + +None. + +#### Command-Specific Flags + +| Long | Short | Type | Default | Required | Description | +|------|-------|------|---------|----------|-------------| +| `--help` | `-h` | bool | `false` | optional | Help for recover | + +--- + +### repair + +**Description:** Repair the repository. This is a parent command with subcommands. + +**Usage:** `restic repair [command]` + +**Subcommands:** `index`, `packs`, `snapshots` + +#### Command-Specific Flags + +| Long | Short | Type | Default | Required | Description | +|------|-------|------|---------|----------|-------------| +| `--help` | `-h` | bool | `false` | optional | Help for repair | + +--- + +### repair index + +**Description:** Build a new index based on the pack files in the repository. + +**Usage:** `restic repair index [flags]` + +**Exit codes:** 0 = success, 1 = error, 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password. + +#### Positional Arguments + +None. + +#### Command-Specific Flags + +| Long | Short | Type | Default | Required | Description | +|------|-------|------|---------|----------|-------------| +| `--help` | `-h` | bool | `false` | optional | Help for index | +| `--read-all-packs` | | bool | `false` | optional | Read all pack files to generate new index from scratch | + +--- + +### repair packs + +**Description:** Salvage damaged pack files. Extracts intact blobs from specified pack files, rebuilds the index, and removes the pack files from the repository. + +**Usage:** `restic repair packs [packIDs...] [flags]` + +**Exit codes:** 0 = success, 1 = error, 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password. + +#### Positional Arguments + +| Name | Required | Description | +|------|----------|-------------| +| `packIDs ...` | **required** | Pack IDs to repair | + +#### Command-Specific Flags + +| Long | Short | Type | Default | Required | Description | +|------|-------|------|---------|----------|-------------| +| `--help` | `-h` | bool | `false` | optional | Help for packs | + +--- + +### repair snapshots + +**Description:** Repair broken snapshots. Scans given snapshots and generates new ones with damaged directories and file contents removed. + +**Usage:** `restic repair snapshots [flags] [snapshot ID] [...]` + +**Exit codes:** 0 = success, 1 = error, 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password. + +**Warning:** Repairing and deleting broken snapshots causes data loss! It will remove broken directories and modify broken files. Depends on a correct index — run `repair index` first! + +#### Positional Arguments + +| Name | Required | Description | +|------|----------|-------------| +| `snapshot ID ...` | optional | Specific snapshot IDs to repair (if omitted, uses filter flags to select) | + +#### Command-Specific Flags + +| Long | Short | Type | Default | Required | Description | +|------|-------|------|---------|----------|-------------| +| `--dry-run` | `-n` | bool | `false` | optional | Do not do anything, just print what would be done | +| `--forget` | | bool | `false` | optional | Remove original snapshots after creating new ones | +| `--help` | `-h` | bool | `false` | optional | Help for snapshots | +| `--host` | `-H` | host (string, repeatable) | `$RESTIC_HOST` | optional | Only consider snapshots for this host (can be specified multiple times) | +| `--path` | | path (string, repeatable) | — | optional | Only consider snapshots including this (absolute) path | +| `--tag` | | tag[,tag,...] (string-list, repeatable) | `[]` | optional | Only consider snapshots including tag[,tag,...] (can be specified multiple times) | + +--- + +### restore + +**Description:** Extract the data from a snapshot. + +**Usage:** `restic restore [flags] snapshotID` + +**Exit codes:** 0 = success, 1 = error, 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password. + +#### Positional Arguments + +| Name | Required | Description | +|------|----------|-------------| +| `snapshotID` | **required** | Snapshot ID to restore (special value `latest` supported; supports `snapshotID:subfolder` syntax) | + +#### Command-Specific Flags + +| Long | Short | Type | Default | Required | Description | +|------|-------|------|---------|----------|-------------| +| `--delete` | | bool | `false` | optional | Delete files from target directory if they do not exist in snapshot. Use '--dry-run -vv' to check what would be deleted | +| `--dry-run` | | bool | `false` | optional | Do not write any data, just show what would be done | +| `--exclude` | `-e` | pattern (string, repeatable) | — | optional | Exclude a pattern (can be specified multiple times) | +| `--exclude-file` | | file (string, repeatable) | — | optional | Read exclude patterns from a file (can be specified multiple times) | +| `--exclude-xattr` | | pattern (string, repeatable) | — | optional | Exclude xattr by pattern (can be specified multiple times) | +| `--help` | `-h` | bool | `false` | optional | Help for restore | +| `--host` | `-H` | host (string, repeatable) | `$RESTIC_HOST` | optional | Only consider snapshots for this host, when snapshot ID "latest" is given | +| `--iexclude` | | pattern (string, repeatable) | — | optional | Same as --exclude but ignores the casing of filenames | +| `--iexclude-file` | | file (string, repeatable) | — | optional | Same as --exclude-file but ignores casing of filenames in patterns | +| `--iinclude` | | pattern (string, repeatable) | — | optional | Same as --include but ignores the casing of filenames | +| `--iinclude-file` | | file (string, repeatable) | — | optional | Same as --include-file but ignores casing of filenames in patterns | +| `--include` | `-i` | pattern (string, repeatable) | — | optional | Include a pattern (can be specified multiple times) | +| `--include-file` | | file (string, repeatable) | — | optional | Read include patterns from a file (can be specified multiple times) | +| `--include-xattr` | | pattern (string, repeatable) | — | optional | Include xattr by pattern (can be specified multiple times) | +| `--overwrite` | | behavior (string) | `always` | optional | Overwrite behavior, one of (always\|if-changed\|if-newer\|never) | +| `--path` | | path (string, repeatable) | — | optional | Only consider snapshots including this (absolute) path, when snapshot ID "latest" is given | +| `--sparse` | | bool | `false` | optional | Restore files as sparse | +| `--tag` | | tag[,tag,...] (string-list, repeatable) | `[]` | optional | Only consider snapshots including tag[,tag,...], when snapshot ID "latest" is given | +| `--target` | `-t` | string | — | **required** | Directory to extract data to | +| `--verify` | | bool | `false` | optional | Verify restored files content | + +--- + +### rewrite + +**Description:** Rewrite snapshots to exclude unwanted files. Creates new snapshots with the excluded files removed. + +**Usage:** `restic rewrite [flags] [snapshotID ...]` + +**Exit codes:** 0 = success, 1 = error, 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password. + +**Note:** The special tag 'rewrite' will be added to new snapshots unless `--forget` is used. + +#### Positional Arguments + +| Name | Required | Description | +|------|----------|-------------| +| `snapshotID ...` | optional | Specific snapshot IDs to rewrite (if omitted, rewrites all matching snapshots) | + +#### Command-Specific Flags + +| Long | Short | Type | Default | Required | Description | +|------|-------|------|---------|----------|-------------| +| `--dry-run` | `-n` | bool | `false` | optional | Do not do anything, just print what would be done | +| `--exclude` | `-e` | pattern (string, repeatable) | — | optional | Exclude a pattern (can be specified multiple times) | +| `--exclude-file` | | file (string, repeatable) | — | optional | Read exclude patterns from a file (can be specified multiple times) | +| `--forget` | | bool | `false` | optional | Remove original snapshots after creating new ones | +| `--help` | `-h` | bool | `false` | optional | Help for rewrite | +| `--host` | `-H` | host (string, repeatable) | `$RESTIC_HOST` | optional | Only consider snapshots for this host (can be specified multiple times) | +| `--iexclude` | | pattern (string, repeatable) | — | optional | Same as --exclude but ignores the casing of filenames | +| `--iexclude-file` | | file (string, repeatable) | — | optional | Same as --exclude-file but ignores casing of filenames in patterns | +| `--new-host` | | string | — | optional | Replace hostname | +| `--new-time` | | string | — | optional | Replace time of the backup | +| `--path` | | path (string, repeatable) | — | optional | Only consider snapshots including this (absolute) path | +| `--snapshot-summary` | `-s` | bool | `false` | optional | Create snapshot summary record if it does not exist | +| `--tag` | | tag[,tag,...] (string-list, repeatable) | `[]` | optional | Only consider snapshots including tag[,tag,...] (can be specified multiple times) | + +--- + +### snapshots + +**Description:** List all snapshots stored in the repository. + +**Usage:** `restic snapshots [flags] [snapshotID ...]` + +**Exit codes:** 0 = success, 1 = error, 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password. + +#### Positional Arguments + +| Name | Required | Description | +|------|----------|-------------| +| `snapshotID ...` | optional | Specific snapshot IDs to list (if omitted, lists all matching snapshots) | + +#### Command-Specific Flags + +| Long | Short | Type | Default | Required | Description | +|------|-------|------|---------|----------|-------------| +| `--compact` | `-c` | bool | `false` | optional | Use compact output format | +| `--group-by` | `-g` | group (string) | — | optional | Group snapshots by host, paths and/or tags, separated by comma | +| `--help` | `-h` | bool | `false` | optional | Help for snapshots | +| `--host` | `-H` | host (string, repeatable) | `$RESTIC_HOST` | optional | Only consider snapshots for this host (can be specified multiple times) | +| `--latest` | | int | — | optional | Only show the last n snapshots for each host and path | +| `--path` | | path (string, repeatable) | — | optional | Only consider snapshots including this (absolute) path | +| `--tag` | | tag[,tag,...] (string-list, repeatable) | `[]` | optional | Only consider snapshots including tag[,tag,...] (can be specified multiple times) | + +--- + +### stats + +**Description:** Scan the repository and show basic statistics. + +**Usage:** `restic stats [flags] [snapshot ID] [...]` + +**Exit codes:** 0 = success, 1 = error, 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password. + +**Modes:** +- `restore-size` (default): Counts the size of the restored files +- `files-by-contents`: Counts total size of unique files (unique by contents) +- `raw-data`: Counts the size of blobs in the repository +- `blobs-per-file`: Combination of files-by-contents and raw-data + +#### Positional Arguments + +| Name | Required | Description | +|------|----------|-------------| +| `snapshot ID ...` | optional | Specific snapshot IDs to compute stats for (if omitted, uses all matching snapshots; `latest` supported) | + +#### Command-Specific Flags + +| Long | Short | Type | Default | Required | Description | +|------|-------|------|---------|----------|-------------| +| `--help` | `-h` | bool | `false` | optional | Help for stats | +| `--host` | `-H` | host (string, repeatable) | `$RESTIC_HOST` | optional | Only consider snapshots for this host (can be specified multiple times) | +| `--mode` | | string | `"restore-size"` | optional | Counting mode: restore-size, files-by-contents, blobs-per-file or raw-data | +| `--path` | | path (string, repeatable) | — | optional | Only consider snapshots including this (absolute) path | +| `--tag` | | tag[,tag,...] (string-list, repeatable) | `[]` | optional | Only consider snapshots including tag[,tag,...] (can be specified multiple times) | + +--- + +### tag + +**Description:** Modify tags on existing snapshots. Can set/replace, add to, or remove from the tag set. + +**Usage:** `restic tag [flags] [snapshotID ...]` + +**Exit codes:** 0 = success, 1 = error, 10 = repo does not exist, 11 = repo already locked, 12 = incorrect password. + +#### Positional Arguments + +| Name | Required | Description | +|------|----------|-------------| +| `snapshotID ...` | optional | Specific snapshot IDs to modify (if omitted, modifies all matching snapshots) | + +#### Command-Specific Flags + +| Long | Short | Type | Default | Required | Description | +|------|-------|------|---------|----------|-------------| +| `--add` | | tags (string-list, repeatable) | `[]` | optional | Tags to add to existing tags in the format `tag[,tag,...]` (can be given multiple times) | +| `--help` | `-h` | bool | `false` | optional | Help for tag | +| `--host` | `-H` | host (string, repeatable) | `$RESTIC_HOST` | optional | Only consider snapshots for this host (can be specified multiple times) | +| `--path` | | path (string, repeatable) | — | optional | Only consider snapshots including this (absolute) path | +| `--remove` | | tags (string-list, repeatable) | `[]` | optional | Tags to remove from existing tags in the format `tag[,tag,...]` (can be given multiple times) | +| `--set` | | tags (string-list, repeatable) | `[]` | optional | Tags to replace existing tags with in the format `tag[,tag,...]` (can be given multiple times) | +| `--tag` | | tag[,tag,...] (string-list, repeatable) | `[]` | optional | Only consider snapshots including tag[,tag,...] (can be specified multiple times) | + +--- + +### unlock + +**Description:** Remove locks other processes created. By default only removes stale locks. + +**Usage:** `restic unlock [flags]` + +**Exit codes:** 0 = success, 1 = error. + +#### Positional Arguments + +None. + +#### Command-Specific Flags + +| Long | Short | Type | Default | Required | Description | +|------|-------|------|---------|----------|-------------| +| `--help` | `-h` | bool | `false` | optional | Help for unlock | +| `--remove-all` | | bool | `false` | optional | Remove all locks, even non-stale ones | + +--- + +## Advanced / Additional Commands + +--- + +### features + +**Description:** Print list of supported feature flags. + +**Usage:** `restic features [flags]` + +**Exit codes:** 0 = success, 1 = error. + +**Note:** Feature flags are controlled via the `RESTIC_FEATURES` environment variable (e.g. `featureA=true,featureB=false`). Feature states: alpha (disabled by default), beta (enabled by default), stable (always enabled), deprecated (always disabled). + +#### Positional Arguments + +None. + +#### Command-Specific Flags + +| Long | Short | Type | Default | Required | Description | +|------|-------|------|---------|----------|-------------| +| `--help` | `-h` | bool | `false` | optional | Help for features | + +--- + +### options + +**Description:** Print list of extended options. + +**Usage:** `restic options [flags]` + +**Exit codes:** 0 = success, 1 = error. + +#### Positional Arguments + +None. + +#### Command-Specific Flags + +| Long | Short | Type | Default | Required | Description | +|------|-------|------|---------|----------|-------------| +| `--help` | `-h` | bool | `false` | optional | Help for options | + +--- + +### generate + +**Description:** Generate manual pages and auto-completion files (bash, fish, zsh, powershell). + +**Usage:** `restic generate [flags]` + +**Exit codes:** 0 = success, 1 = error. + +#### Positional Arguments + +None. + +#### Command-Specific Flags + +| Long | Short | Type | Default | Required | Description | +|------|-------|------|---------|----------|-------------| +| `--bash-completion` | | file (string) | — | optional | Write bash completion file (`-` for stdout) | +| `--fish-completion` | | file (string) | — | optional | Write fish completion file (`-` for stdout) | +| `--help` | `-h` | bool | `false` | optional | Help for generate | +| `--man` | | directory (string) | — | optional | Write man pages to directory | +| `--powershell-completion` | | file (string) | — | optional | Write powershell completion file (`-` for stdout) | +| `--zsh-completion` | | file (string) | — | optional | Write zsh completion file (`-` for stdout) | + +--- + +### version + +**Description:** Print version information. + +**Usage:** `restic version` + +#### Positional Arguments + +None. + +#### Command-Specific Flags + +None (only global flags apply). + +--- + +## Quick Reference: Commands Requiring Positional Arguments + +| Command | Required Positional Args | +|---------|------------------------| +| `backup` | At least one source (FILE/DIR, --files-from*, or --stdin) | +| `cat` | object type + ID | +| `diff` | snapshotID snapshotID (exactly two) | +| `dump` | snapshotID file (exactly two) | +| `find` | PATTERN... (one or more) | +| `key remove` | ID (exactly one) | +| `list` | object type (exactly one) | +| `ls` | snapshotID (exactly one) | +| `mount` | mountpoint (exactly one) | +| `repair packs` | packIDs... (one or more) | +| `restore` | snapshotID (exactly one) | + +## Quick Reference: Required Flags + +| Command | Required Flags | +|---------|---------------| +| `restore` | `--target` (`-t`) — directory to extract data to | +| `copy` | `--from-repo` (or `$RESTIC_FROM_REPOSITORY`) — source repository | +| All commands needing repo access | `--repo` (`-r`) or `$RESTIC_REPOSITORY` (or `--repository-file` / `$RESTIC_REPOSITORY_FILE`) | +| All commands needing repo access | A password source: interactive prompt, `--password-file`, `--password-command`, or `$RESTIC_PASSWORD` |