Gate prune's local-cleanup output on --json, and make make build build (closes #108)
All checks were successful
check / check (push) Successful in 2m16s

Closes #110.

CleanupLocalSnapshots wrote three prose lines to stdout with no --json
awareness, covering every branch, so `vaultik prune --json | jq` failed
on any input. -q never helped either: printlnStdout and stdoutf write
straight to v.Stdout and never consult v.UI, which is what SetQuiet
affects. It now takes *PruneOptions, symmetric with its sibling phase
PruneBlobs, and gates all three writes.

Threading opts.JSON was chosen over moving the lines to log.Info,
because internal/log/log.go defaults the level to Warn: log.Info would
not have relocated them to stderr, it would have deleted them from a
plain `vaultik prune`, and "Removing stale local record" narrates the
deletion of local index rows. The stale-record count is deliberately not
added to PruneBlobsResult - every field there is blob-scoped and produced
by the phase that runs after this reconciliation, so adding it would
change a published --json schema as a side effect of a stream fix.

Note for anyone reading the --json contract: under --json the
stale-record removal now produces no signal in either stream. stdout is
correctly gated, stderr is level-pinned to Warn because --json sets
Quiet, and the count is not in the document. That is inherited behaviour
- PruneBlobs' own log.Info calls are equally invisible under --json - not
something this change introduced, and it is tracked separately.

make build exited 0 and produced nothing: .PHONY listed build with no
build: rule, and a phony target with no prerequisites and no recipe is
considered already satisfied, which turns what would be a hard error into
a silent success. In a repo where `make build` is the documented way to
build, a caller checking the exit code concluded the build worked. Now
`build: vaultik`, verified in both directions - a clean build produces
the binary, a deliberately broken one exits non-zero and produces none.

All 19 .PHONY names were audited; build was the only one lacking a rule.
TestPhonyTargetsAllHaveRules keeps that true for names added later, so
the class is closed rather than the instance.
This commit was merged in pull request #111.
This commit is contained in:
2026-08-09 19:53:46 +02:00
parent f21e7c9e70
commit 696ed9ab4d
9 changed files with 574 additions and 12 deletions

View File

@@ -66,7 +66,18 @@ lint:
lint-fix: lint-fix:
@script/lint-fix @script/lint-fix
# Build binary. # Build binary. `build` is the name the org convention reaches for and
# the one a caller checks the exit code of; `vaultik` is the file rule
# that does the work, so an unchanged tree still short-circuits.
#
# This alias is not decorative. `build` was listed in .PHONY with no
# rule, and a phony target with no prerequisites and no recipe is
# already satisfied: `make build` printed "Nothing to be done" and
# exited 0 without producing a binary (issue #110). Every name in
# .PHONY needs a rule for that reason; TestPhonyTargetsAllHaveRules in
# cmd/vaultik keeps it that way.
build: vaultik
vaultik: internal/*/*.go cmd/vaultik/*.go vaultik: internal/*/*.go cmd/vaultik/*.go
go build -ldflags "$(LDFLAGS)" -o $@ ./cmd/vaultik go build -ldflags "$(LDFLAGS)" -o $@ ./cmd/vaultik

View File

@@ -139,10 +139,13 @@ Format follows the stream: when stderr is a terminal the records are
colorized one-liners, and when it is redirected or piped they are colorized one-liners, and when it is redirected or piped they are
JSON, one object per line. JSON, one object per line.
The startup banner is the other thing that writes to stdout, and Under `--json`, stdout holds the document and nothing else. The startup
`--json` suppresses it, as `--quiet` and `--cron` do. So banner is suppressed, as `--quiet` and `--cron` suppress it, and the
`vaultik snapshot list --json | jq .` works on its own, with no progress narration a command would otherwise print — such as the stale
additional flag to quiet the banner first. local records `prune` reconciles away — is suppressed too, so it cannot
land ahead of the document. Every `--json` command therefore pipes on
its own, with no additional flag: `vaultik snapshot list --json | jq .`
and `vaultik prune --json | jq .` both work as written.
### environment variables ### environment variables

56
TODO.md
View File

@@ -25,6 +25,62 @@ release" is exactly the contradiction
# Completed Steps # Completed Steps
- 2026-08-09: Finished the `--json` stdout contract and gave `make build`
a rule ([issue #108](https://git.eeqj.de/sneak/vaultik/issues/108),
[issue #110](https://git.eeqj.de/sneak/vaultik/issues/110)). Two
unrelated defects of the same shape — a command reporting something it
did not do — landed together because both are small.
`CleanupLocalSnapshots` wrote three prose lines to stdout with no
`--json` awareness, covering every branch of the function, so no input
avoided them and `vaultik prune --json | jq` failed even after
[issue #106](https://git.eeqj.de/sneak/vaultik/issues/106) removed the
banner. `-q` never helped either: `printlnStdout` and `stdoutf` write
straight to `Vaultik.Stdout` and never consult `Vaultik.UI`, which is
what `SetQuiet` affects. The issue offered three fixes and asked for a
decision. Taken: thread `*PruneOptions` into the function and gate each
write on `!opts.JSON`, matching `PruneBlobs` (its sibling phase, which
already takes the same struct), `RemoveSnapshot` and `remote info`, so
the package has one pattern rather than two. Rejected: moving the lines
to `log.Info`, because the logger's default level is `slog.LevelWarn`,
so that would not relocate them to stderr — it would delete them from a
plain `vaultik prune`, and the removal of rows from the local index is
not something to narrate only under `--verbose`. Also rejected: putting
the stale-record count into `PruneBlobsResult`, whose every field is
blob-scoped and which is produced by the later phase; a prune document
covering both phases is a reasonable thing to want, but it is a schema
design question and not a stream-hygiene fix. The narration is
duplicated as `log.Info` records, which `PruneBlobs` already does
alongside its own prints, so the events survive on stderr for anyone
running `--verbose`.
`make build` printed "Nothing to be done for 'build'" and exited 0
without producing a binary: `build` was listed in `.PHONY` with no
`build:` rule anywhere, and declaring a name phony is exactly what
converts make's "No rule to make target" error into a silent success.
Fixed with `build: vaultik`, keeping `vaultik:` as the file rule. The
audit the issue asked for covers all 19 `.PHONY` names; `build` was the
only one without a rule, and `vaultik` is correctly absent from
`.PHONY`, being a real file target.
Tests, each verified to fail with the fix reverted rather than assumed
to: `CleanupLocalSnapshots` leaves stdout untouched under `--json` in
all three branches (stale records, none, empty index) and still emits
every line without it, so the guard cannot be satisfied by deleting the
output; `prune --json` run end to end through `Entry`, cobra and fx
over the process's real stdout descriptor against a `file://` store,
asserting exactly one JSON document, in both the stale and non-stale
branches; and a parse of the `Makefile` asserting every `.PHONY` name
has a rule and that `build` reaches the rule that produces the binary,
which keeps the audit true for names added later. That last one is a
parse rather than an invocation of `make`, since `make test` is what
runs it and shelling back into `make build` would nest a build inside
the test run. The property a parse cannot establish — that the recipe
still fails when the build fails — was verified by hand against a
deliberately broken tree: `make build` exits 2 and produces nothing.
`cmd/vaultik` gains its first test file, so `make test` now reports 16
packages `ok` where it reported 15.
- 2026-08-09: Stopped the startup banner from contaminating `--json` - 2026-08-09: Stopped the startup banner from contaminating `--json`
documents ([issue #106](https://git.eeqj.de/sneak/vaultik/issues/106)). documents ([issue #106](https://git.eeqj.de/sneak/vaultik/issues/106)).
`Entry` writes the banner to stdout before cobra parses anything, and `Entry` writes the banner to stdout before cobra parses anything, and

View File

@@ -0,0 +1,169 @@
package main_test
import (
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// This file guards the Makefile that builds this program, which is why
// it lives beside it rather than in a package of its own.
//
// Issue #110: `build` was listed in .PHONY with no `build:` rule
// anywhere in the file. That combination is silently successful — make
// considers a phony target with no prerequisites and no recipe already
// satisfied, so `rm -f vaultik && make build` printed "Nothing to be
// done for 'build'" and exited 0 with no binary produced. Declaring the
// name phony is precisely what converts the "No rule to make target"
// error into a green.
//
// The guard is a parse of the Makefile rather than an invocation of
// make. `make test` is what runs these tests, so shelling back into
// `make build` here would nest a build inside the test run and drop a
// binary into the tree as a side effect of testing. The one property a
// parse cannot establish — that the recipe still fails when the build
// fails — is not testable from inside the build either; it is verified
// by hand against a deliberately broken tree.
// phonyDirective introduces the list of phony target names.
const phonyDirective = ".PHONY:"
// ruleLine matches a rule's target list: a target starts in column
// zero, so recipe lines (tab-indented) and the continuation lines of a
// variable assignment (space-indented) are excluded by construction.
//
// The trailing (?:[^=]|$) rejects `:=` assignments such as
// `VERSION := $(shell script/version)`, which are not rules. Directives
// and function calls (`.PHONY:`, `ifeq`, `$(error ...)`) do not match
// because a target here must begin with a letter, digit or underscore.
var ruleLine = regexp.MustCompile(`^([A-Za-z0-9_][A-Za-z0-9_./ -]*):(?:[^=]|$)`)
// TestPhonyTargetsAllHaveRules fails on any name in .PHONY that has no
// rule in the Makefile. Such a name is not a build target at all: it is
// a command that reports success without doing anything, which is worse
// than one that does not exist, because a caller checking the exit code
// cannot tell the difference.
func TestPhonyTargetsAllHaveRules(t *testing.T) {
t.Parallel()
makefile := readMakefile(t)
phony := phonyTargets(makefile)
require.NotEmpty(t, phony, "no .PHONY names found; the parser is broken")
rules := declaredRules(makefile)
// Sanity check on the rule parser before trusting its verdict: a
// parser that found nothing would pass this test by accident.
require.Contains(t, rules, "vaultik",
"the file rule that builds the binary must be recognized")
for _, target := range phony {
assert.Contains(t, rules, target,
"`.PHONY` lists %q but the Makefile declares no %q rule, so "+
"`make %s` exits 0 without doing anything", target, target, target)
}
}
// TestBuildTargetBuildsTheBinary pins the specific shape of issue #110:
// `make build` has to reach the rule that produces the binary. The test
// above would also pass if `build:` were given an empty recipe of its
// own, which would be the same silent success under a different
// spelling.
func TestBuildTargetBuildsTheBinary(t *testing.T) {
t.Parallel()
prerequisites := rulePrerequisites(readMakefile(t), "build")
require.NotNil(t, prerequisites, "the Makefile declares no `build` rule")
assert.Contains(t, prerequisites, "vaultik",
"`make build` must depend on the rule that builds the binary")
}
// readMakefile returns the contents of the repository's Makefile. The
// test binary runs with its package directory as the working directory,
// so the root is found by walking up until the Makefile appears.
func readMakefile(t *testing.T) string {
t.Helper()
dir, err := os.Getwd()
require.NoError(t, err)
for {
//nolint:gosec // G304: the path is this test's own directory walk
contents, err := os.ReadFile(filepath.Join(dir, "Makefile"))
if err == nil {
return string(contents)
}
parent := filepath.Dir(dir)
require.NotEqual(t, dir, parent,
"walked to the filesystem root without finding a Makefile")
dir = parent
}
}
// phonyTargets returns every name declared phony, across all .PHONY
// lines.
func phonyTargets(makefile string) []string {
var targets []string
for line := range strings.SplitSeq(makefile, "\n") {
if !strings.HasPrefix(line, phonyDirective) {
continue
}
targets = append(targets,
strings.Fields(strings.TrimPrefix(line, phonyDirective))...)
}
return targets
}
// declaredRules returns the set of target names that have a rule.
func declaredRules(makefile string) map[string]bool {
rules := make(map[string]bool)
for line := range strings.SplitSeq(makefile, "\n") {
match := ruleLine.FindStringSubmatch(line)
if match == nil {
continue
}
// One rule may name several targets: `a b: prereq`.
for target := range strings.FieldsSeq(match[1]) {
rules[target] = true
}
}
return rules
}
// rulePrerequisites returns the prerequisites of the named rule, or nil
// if no such rule exists. A rule with none returns an empty slice, so
// "declared with nothing to do" is distinguishable from "not declared".
func rulePrerequisites(makefile, target string) []string {
for line := range strings.SplitSeq(makefile, "\n") {
match := ruleLine.FindStringSubmatch(line)
if match == nil {
continue
}
if !slices.Contains(strings.Fields(match[1]), target) {
continue
}
_, after, _ := strings.Cut(line, ":")
return append([]string{}, strings.Fields(after)...)
}
return nil
}

View File

@@ -30,6 +30,11 @@ const (
flagJSON = "--json" flagJSON = "--json"
flagQuiet = "--quiet" flagQuiet = "--quiet"
flagConfig = "--config"
// programName is argv[0] as the real process receives it. Entry
// strips it before scanning, so it has to be present.
programName = "vaultik"
// someSnapshotID is any snapshot identifier: these tests never run // someSnapshotID is any snapshot identifier: these tests never run
// the command, so it only has to occupy the positional argument. // the command, so it only has to occupy the positional argument.
@@ -65,7 +70,7 @@ var jsonArgumentVectors = map[string][]string{
// A --json invocation that also carries a flag with a value, so the // A --json invocation that also carries a flag with a value, so the
// scan cannot be fooled by an argument that consumes the next one. // scan cannot be fooled by an argument that consumes the next one.
"json with config": { "json with config": {
"--config", "/nonexistent/vaultik.yml", cmdSnapshot, cmdList, flagJSON, flagConfig, "/nonexistent/vaultik.yml", cmdSnapshot, cmdList, flagJSON,
}, },
} }
@@ -222,7 +227,7 @@ func TestEntryJSONStdoutIsExactlyOneDocument(t *testing.T) {
}) })
os.Args = []string{ os.Args = []string{
"vaultik", "--config", configPath, cmdSnapshot, cmdList, flagJSON, programName, flagConfig, configPath, cmdSnapshot, cmdList, flagJSON,
} }
stdout := captureProcessStdout(t, Entry) stdout := captureProcessStdout(t, Entry)

View File

@@ -0,0 +1,165 @@
package cli //nolint:testpackage // shares hermeticConfig and the capture helpers
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"os"
"path/filepath"
"testing"
"time"
"github.com/adrg/xdg"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/database"
"sneak.berlin/go/vaultik/internal/types"
)
// pruneJSONDocument is the shape `prune --json` writes: the
// PruneBlobsResult document, and nothing else.
//
//nolint:tagliatelle // snake_case is the established JSON output format
type pruneJSONDocument struct {
BlobsFound int `json:"blobs_found"`
BlobsDeleted int `json:"blobs_deleted"`
BytesFreed int64 `json:"bytes_freed"`
}
// stalePruneSnapshotID is seeded into the local index with no manifest
// on the destination store, which is exactly what makes it stale.
const stalePruneSnapshotID = "test-host_test_2026-04-01T09:00:00Z"
// TestEntryPruneJSONStdoutIsExactlyOneDocument is the end-to-end
// regression guard for issue #108: `vaultik prune --json | jq .` must
// work with no other flags.
//
// It runs Entry over the process's real stdout descriptor, through
// cobra and the fx graph, against a hermetic file:// destination store
// — the same construction TestEntryJSONStdoutIsExactlyOneDocument uses
// for `snapshot list`, with the pipe to jq replaced by a decoder.
//
// Both branches of the local-snapshot reconciliation are exercised
// because the three stdout writes that broke this covered all of them:
// one line per stale record and a summary when there were any, and a
// "No stale local snapshots found." line when there were none. No input
// avoided the contamination, so no single branch demonstrates the fix.
//
// Not parallel: it replaces os.Args, os.Stdout and the xdg globals.
//
//nolint:paralleltest // replaces os.Args, os.Stdout and the xdg globals
func TestEntryPruneJSONStdoutIsExactlyOneDocument(t *testing.T) {
for _, testCase := range []struct {
name string
seedStale bool
description string
}{
{
name: "no stale local records",
seedStale: false,
description: "the empty-index branch used to print a 'No stale' line",
},
{
name: "stale local records present",
seedStale: true,
description: "the removal branch used to print a line per record " +
"plus a summary",
},
} {
t.Run(testCase.name, func(t *testing.T) {
configPath := writeHermeticPruneConfig(t, testCase.seedStale)
previousArgs := os.Args
t.Cleanup(func() {
os.Args = previousArgs
rootFlags = RootFlags{}
})
os.Args = []string{
programName, flagConfig, configPath, cmdPrune, flagJSON,
}
stdout := captureProcessStdout(t, Entry)
requireExactlyOneJSONDocument(t, stdout)
var document pruneJSONDocument
require.NoError(t, json.Unmarshal([]byte(stdout), &document),
testCase.description)
// A destination store with no blobs has none to prune. The
// assertion that matters is the one above; this one keeps the
// test honest about which document it decoded.
assert.Equal(t, 0, document.BlobsFound)
})
}
}
// writeHermeticPruneConfig builds a config over a temp directory and, if
// seedStale is set, creates the index database up front with one
// snapshot record that has no counterpart on the destination store.
// Returns the config path.
func writeHermeticPruneConfig(t *testing.T, seedStale bool) string {
t.Helper()
dir := t.TempDir()
configPath := filepath.Join(dir, "config.yml")
indexPath := filepath.Join(dir, "index.sqlite")
contents := fmt.Sprintf(hermeticConfig,
filepath.Join(dir, "source"),
filepath.Join(dir, "store"),
indexPath)
require.NoError(t,
os.WriteFile(configPath, []byte(contents), configFileMode))
// The PID lock lives under xdg.DataHome, which xdg resolves at
// package init; point it at the temp dir so the test neither
// touches nor collides with the real one.
t.Setenv("XDG_DATA_HOME", filepath.Join(dir, "data"))
xdg.Reload()
t.Cleanup(xdg.Reload)
if seedStale {
seedStaleSnapshotRecord(t, indexPath)
}
return configPath
}
// seedStaleSnapshotRecord creates the index database at path and
// inserts one completed snapshot into it. Nothing is written to the
// destination store, so `prune` finds the record stale and removes it —
// the branch that printed a line per record.
func seedStaleSnapshotRecord(t *testing.T, path string) {
t.Helper()
ctx := context.Background()
db, err := database.New(ctx, path)
require.NoError(t, err)
defer func() { require.NoError(t, db.Close()) }()
startedAt := time.Date(2026, 4, 1, 9, 0, 0, 0, time.UTC)
completedAt := startedAt.Add(time.Minute)
snap := &database.Snapshot{
ID: types.SnapshotID(stalePruneSnapshotID),
Hostname: "test-host",
VaultikVersion: "test",
StartedAt: startedAt,
CompletedAt: &completedAt,
}
repos := database.NewRepositories(db)
err = repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
return repos.Snapshots.Create(ctx, tx, snap)
})
require.NoError(t, err)
}

View File

@@ -79,7 +79,7 @@ func (v *Vaultik) Prune(opts *PruneOptions) error {
// store is treated as gone. This used to be the separate 'snapshot // store is treated as gone. This used to be the separate 'snapshot
// cleanup' command and is now folded in so a single 'vaultik prune' // cleanup' command and is now folded in so a single 'vaultik prune'
// gets the local index fully back in sync with the destination. // gets the local index fully back in sync with the destination.
err = v.CleanupLocalSnapshots() err = v.CleanupLocalSnapshots(opts)
if err != nil { if err != nil {
return fmt.Errorf("reconciling local snapshots with remote: %w", err) return fmt.Errorf("reconciling local snapshots with remote: %w", err)
} }

View File

@@ -0,0 +1,134 @@
package vaultik_test
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/vaultik"
)
// cleanupStaleID is a local snapshot record with no remote manifest —
// the record CleanupLocalSnapshots exists to remove.
const cleanupStaleID = "testhost_home_2026-04-01T09:00:00Z"
// remainingSnapshotLimit bounds the post-cleanup listing. ListRecent
// takes a SQL LIMIT, so it must be positive; the fixtures never exceed
// a handful of rows.
const remainingSnapshotLimit = 100
// cleanupStart is the fixture snapshot's start time. Its exact value is
// irrelevant; only presence in the index matters here.
//
//nolint:gochecknoglobals // read-only fixture shared by the tests below
var cleanupStart = time.Date(2026, 4, 1, 9, 0, 0, 0, time.UTC)
// TestCleanupLocalSnapshots_JSONWritesNothingToStdout is the regression
// guard for issue #108: `vaultik prune --json | jq` failed because this
// function wrote prose to stdout on every branch, ahead of the
// PruneBlobsResult document, with no --json awareness at all.
//
// Both branches are covered because the three writes between them left
// no input that avoided the contamination: with stale records there was
// a line per record plus a summary, and with none there was still the
// "No stale local snapshots found." line.
func TestCleanupLocalSnapshots_JSONWritesNothingToStdout(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
for name, seed := range map[string]func(*listEnv){
"no stale records": func(env *listEnv) {
// A snapshot present both locally and remotely: nothing to
// remove, which used to print the "No stale" line.
env.addLocal(t, listLocalID, cleanupStart)
env.addRemote(t, listLocalID, cleanupStart)
},
"stale records present": func(env *listEnv) {
env.addLocal(t, cleanupStaleID, cleanupStart)
},
"nothing at all": func(_ *listEnv) {},
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
env := newListEnv(t)
seed(env)
err := env.v.CleanupLocalSnapshots(&vaultik.PruneOptions{JSON: true})
require.NoError(t, err)
assert.Empty(t, env.stdout.String(),
"stdout carries the --json document and nothing else")
})
}
}
// TestCleanupLocalSnapshots_HumanOutputRetained pins the other half of
// the contract. Without it the test above would be satisfied by
// deleting the three lines outright, and a `vaultik prune` with no
// flags must still say that it removed records from the local index —
// that is the deletion of local state, not decoration.
func TestCleanupLocalSnapshots_HumanOutputRetained(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
t.Run("stale records present", func(t *testing.T) {
t.Parallel()
env := newListEnv(t)
env.addLocal(t, cleanupStaleID, cleanupStart)
err := env.v.CleanupLocalSnapshots(&vaultik.PruneOptions{})
require.NoError(t, err)
out := env.stdout.String()
assert.Contains(t, out, "Removing stale local record: "+cleanupStaleID)
assert.Contains(t, out, "Removed 1 stale local snapshot record(s).")
})
t.Run("no stale records", func(t *testing.T) {
t.Parallel()
env := newListEnv(t)
env.addLocal(t, listLocalID, cleanupStart)
env.addRemote(t, listLocalID, cleanupStart)
err := env.v.CleanupLocalSnapshots(&vaultik.PruneOptions{})
require.NoError(t, err)
assert.Contains(t, env.stdout.String(),
"No stale local snapshots found.")
})
}
// TestCleanupLocalSnapshots_RemovesOnlyStaleRecords checks that the
// --json gate did not change what the function does, only what it
// says: the stale record is gone from the index and the one with a
// remote manifest is untouched.
func TestCleanupLocalSnapshots_RemovesOnlyStaleRecords(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
env := newListEnv(t)
env.addLocal(t, listLocalID, cleanupStart)
env.addRemote(t, listLocalID, cleanupStart)
env.addLocal(t, cleanupStaleID, cleanupStart)
err := env.v.CleanupLocalSnapshots(&vaultik.PruneOptions{JSON: true})
require.NoError(t, err)
remaining, err := env.v.Repositories.Snapshots.ListRecent(
env.v.Context(), remainingSnapshotLimit)
require.NoError(t, err)
ids := make([]string, 0, len(remaining))
for _, snap := range remaining {
ids = append(ids, snap.ID.String())
}
assert.Equal(t, []string{listLocalID}, ids,
"only the record with no remote manifest may be removed")
}

View File

@@ -829,7 +829,15 @@ func (v *Vaultik) outputVerifyJSON(result *VerifyResult) error {
// behind by incomplete or interrupted backups. Each local snapshot's // behind by incomplete or interrupted backups. Each local snapshot's
// human ID is hashed via RemoteSnapshotKey and compared against the // human ID is hashed via RemoteSnapshotKey and compared against the
// remote listing. // remote listing.
func (v *Vaultik) CleanupLocalSnapshots() error { //
// It takes the whole *PruneOptions, symmetric with PruneBlobs, because
// it is the other half of one command: Prune runs this phase and then
// that one. Only JSON is read here. Under --json every write below is
// suppressed, because stdout carries the PruneBlobsResult document and
// nothing else — prose ahead of it is what made `vaultik prune --json |
// jq` fail (issue #108). The narration is duplicated as log records,
// which go to stderr and so cannot corrupt the document.
func (v *Vaultik) CleanupLocalSnapshots(opts *PruneOptions) error {
err := v.EnsureStorageBinding() err := v.EnsureStorageBinding()
if err != nil { if err != nil {
return err return err
@@ -855,7 +863,11 @@ func (v *Vaultik) CleanupLocalSnapshots() error {
for _, snap := range localSnapshots { for _, snap := range localSnapshots {
id := snap.ID.String() id := snap.ID.String()
if !remoteSet[snapshot.RemoteSnapshotKey(id)] { if !remoteSet[snapshot.RemoteSnapshotKey(id)] {
log.Info("Removing stale local snapshot record", "snapshot_id", id)
if !opts.JSON {
v.stdoutf("Removing stale local record: %s\n", id) v.stdoutf("Removing stale local record: %s\n", id)
}
err = v.deleteSnapshotFromLocalDB(id) err = v.deleteSnapshotFromLocalDB(id)
if err != nil { if err != nil {
@@ -869,6 +881,13 @@ func (v *Vaultik) CleanupLocalSnapshots() error {
} }
} }
log.Info("Reconciled local snapshot records against remote metadata",
"removed", removed, "examined", len(localSnapshots))
if opts.JSON {
return nil
}
if removed == 0 { if removed == 0 {
v.printlnStdout("No stale local snapshots found.") v.printlnStdout("No stale local snapshots found.")
} else { } else {