From 9b790bbadee1566dc46a1eaed0d83174726aee77 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Sun, 3 May 2026 22:20:48 +0100 Subject: [PATCH] restic: RunPrune + runWithPump helper, refactor Forget/Init onto it Add RunPrune for admin-credential prune invocations. Extract runWithPump to DRY the stdout+stderr pump pattern; refactor RunForget and RunInit to delegate to it (RunInit preserves the "config file already exists" soft-success sniff by wrapping the handler before the call). Add runner_test.go with TestRunPruneInvokesPrune. --- internal/restic/runner.go | 100 +++++++++++++++++---------------- internal/restic/runner_test.go | 53 +++++++++++++++++ 2 files changed, 104 insertions(+), 49 deletions(-) create mode 100644 internal/restic/runner_test.go diff --git a/internal/restic/runner.go b/internal/restic/runner.go index 05721af..0b4b671 100644 --- a/internal/restic/runner.go +++ b/internal/restic/runner.go @@ -204,32 +204,7 @@ func (e Env) RunForget(ctx context.Context, policy ForgetPolicy, handle LineHand cmd := exec.CommandContext(ctx, e.Bin, args...) cmd.Env = e.envSlice() cmd.Dir = e.WorkDir - - stdout, err := cmd.StdoutPipe() - if err != nil { - return fmt.Errorf("restic forget: stdout pipe: %w", err) - } - stderr, err := cmd.StderrPipe() - if err != nil { - return fmt.Errorf("restic forget: stderr pipe: %w", err) - } - - if err := cmd.Start(); err != nil { - return fmt.Errorf("restic forget: start: %w", err) - } - - done := make(chan error, 2) - go func() { done <- pumpPlain(stdout, "stdout", handle) }() - go func() { done <- pumpPlain(stderr, "stderr", handle) }() - for i := 0; i < 2; i++ { - if err := <-done; err != nil && handle != nil { - handle("event", fmt.Sprintf("pump error: %v", err), nil) - } - } - if werr := cmd.Wait(); werr != nil { - return fmt.Errorf("restic forget: %w", werr) - } - return nil + return runWithPump(cmd, handle) } // RunInit executes `restic init` against the configured repo. Returns @@ -243,19 +218,6 @@ func (e Env) RunInit(ctx context.Context, handle LineHandler) error { cmd.Env = e.envSlice() cmd.Dir = e.WorkDir - stdout, err := cmd.StdoutPipe() - if err != nil { - return fmt.Errorf("restic init: stdout pipe: %w", err) - } - stderr, err := cmd.StderrPipe() - if err != nil { - return fmt.Errorf("restic init: stderr pipe: %w", err) - } - - if err := cmd.Start(); err != nil { - return fmt.Errorf("restic init: start: %w", err) - } - // Sniff for "config file already exists" on stderr; if we see it // we'll treat the non-zero exit as a soft success — running init // against an already-initialized repo is a no-op semantically, @@ -271,22 +233,62 @@ func (e Env) RunInit(ctx context.Context, handle LineHandler) error { } } - done := make(chan error, 2) - go func() { done <- pumpPlain(stdout, "stdout", sniff) }() - go func() { done <- pumpPlain(stderr, "stderr", sniff) }() - for i := 0; i < 2; i++ { - if err := <-done; err != nil && handle != nil { - handle("event", fmt.Sprintf("pump error: %v", err), nil) - } - } - if werr := cmd.Wait(); werr != nil { + if err := runWithPump(cmd, sniff); err != nil { if alreadyInited { if handle != nil { handle("event", "repo already initialized — treating as success", nil) } return nil } - return fmt.Errorf("restic init: %w", werr) + return err + } + return nil +} + +// RunPrune executes `restic prune` against the configured repo. +// Requires the *admin* credentials (delete access on the rest-server +// repo) — the caller is responsible for populating Env.RepoUsername +// and Env.RepoPassword with the admin pair before calling this. +// +// Prune emits human-readable progress on stdout/stderr (no --json +// support that's useful for our purposes). We tee everything to the +// handler so the live log is the operator's progress bar. +func (e Env) RunPrune(ctx context.Context, handle LineHandler) error { + cmd := exec.CommandContext(ctx, e.Bin, "prune") + cmd.Env = e.envSlice() + cmd.Dir = e.WorkDir + return runWithPump(cmd, handle) +} + +// runWithPump starts the configured cmd, fans stdout+stderr into +// pumpPlain via the supplied handler, waits, and wraps any error +// with the cmd's verb (e.g., "restic prune") for context. +func runWithPump(cmd *exec.Cmd, handle LineHandler) error { + label := "restic" + if len(cmd.Args) > 1 { + label = "restic " + cmd.Args[1] + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("%s: stdout pipe: %w", label, err) + } + stderr, err := cmd.StderrPipe() + if err != nil { + return fmt.Errorf("%s: stderr pipe: %w", label, err) + } + if err := cmd.Start(); err != nil { + return fmt.Errorf("%s: start: %w", label, err) + } + done := make(chan error, 2) + go func() { done <- pumpPlain(stdout, "stdout", handle) }() + go func() { done <- pumpPlain(stderr, "stderr", handle) }() + for i := 0; i < 2; i++ { + if err := <-done; err != nil && handle != nil { + handle("event", fmt.Sprintf("pump error: %v", err), nil) + } + } + if werr := cmd.Wait(); werr != nil { + return fmt.Errorf("%s: %w", label, werr) } return nil } diff --git a/internal/restic/runner_test.go b/internal/restic/runner_test.go new file mode 100644 index 0000000..46c3a05 --- /dev/null +++ b/internal/restic/runner_test.go @@ -0,0 +1,53 @@ +package restic + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +// setupScriptBin writes a small shell script to a temp directory, +// makes it executable, and returns its path. scriptBody is the +// complete script content (without the shebang line — that's added +// automatically). +func setupScriptBin(t *testing.T, scriptBody string) string { + t.Helper() + dir := t.TempDir() + p := filepath.Join(dir, "restic") + content := "#!/bin/sh\n" + scriptBody + "\n" + if err := os.WriteFile(p, []byte(content), 0o755); err != nil { + t.Fatalf("setupScriptBin: %v", err) + } + return p +} + +// captureLines returns a LineHandler that appends "stream:line" into +// the returned slice pointer (safe for single-goroutine test use). +func captureLines() (*[]string, LineHandler) { + var lines []string + h := func(stream, line string, _ any) { + lines = append(lines, fmt.Sprintf("%s:%s", stream, line)) + } + return &lines, h +} + +// --- B1: RunPrune --- + +func TestRunPruneInvokesPrune(t *testing.T) { + // Shell script that echoes its args; "prune" should appear in output. + bin := setupScriptBin(t, `echo "$@"`) + env := Env{Bin: bin} + lines, h := captureLines() + if err := env.RunPrune(context.Background(), h); err != nil { + t.Fatalf("RunPrune returned error: %v", err) + } + for _, l := range *lines { + if strings.Contains(l, "prune") { + return + } + } + t.Fatalf("expected 'prune' in captured output; got: %v", *lines) +}