Do not prune after a backup that found a configured path empty (closes #388)
check / check (push) Waiting to run

snapshot create --prune of a snapshot whose path was an empty
directory, as the mount point of a volume that is not mounted is,
recorded an empty snapshot and then purged the older snapshots that
held the data, with their blobs.

The scan now counts the entries it finds in each configured path.
Under --prune, a run in which a path of a snapshot it made held
nothing skips the prune, keeps every older snapshot, names each such
path with its snapshot, and exits non-zero, as
#382 does for unreadable
files. Only the current run is looked at. Without --prune this check
does nothing. A configured path that is a symlink whose target is
gone now fails every run, with or without --prune.

Model: opus-5-5
This commit is contained in:
2026-10-14 23:53:33 +00:00
committed by sneak
parent 6a57750a7b
commit fd5e077fa0
7 changed files with 418 additions and 47 deletions
@@ -87,6 +87,36 @@ func TestEntrySnapshotCreateReportsPostBackupPruneFailure(t *testing.T) {
"the new snapshot is on the store beside the planted one")
}
// TestEntrySnapshotCreatePruneExitsNonZeroWhenSourceIsEmpty backs up,
// empties the source directory, as a volume that is not mounted leaves
// its mount point, and runs `snapshot create --prune`. The command exits
// non-zero and reports the backup as completed, not as a failed snapshot
// creation. See https://git.eeqj.de/sneak/vaultik/issues/388.
//
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
func TestEntrySnapshotCreatePruneExitsNonZeroWhenSourceIsEmpty(t *testing.T) {
configPath, storeDir := writeBackupConfig(t)
code, _, _ := runEntry(t, flagConfig, configPath, cmdSnapshot, cmdCreate)
require.Equal(t, 0, code)
// writeBackupConfig puts the source directory beside the config file.
require.NoError(t, os.Remove(
filepath.Join(filepath.Dir(configPath), "source", "file.txt")))
code, _, stderr := runEntry(t,
flagConfig, configPath, cmdSnapshot, cmdCreate, flagPrune)
assert.Equal(t, 1, code)
assert.Contains(t, stderr,
"Backup completed, but 1 source path(s) were empty")
assert.NotContains(t, stderr, "Snapshot creation failed")
snapshots, err := os.ReadDir(filepath.Join(storeDir, "metadata"))
require.NoError(t, err)
assert.Len(t, snapshots, 2, "the older snapshot is kept")
}
// writeBackupConfig builds a hermetic config with one small file in its
// source directory and an empty destination store directory. Returns the
// config path and the store directory.
+5 -3
View File
@@ -115,8 +115,9 @@ on Linux), then /etc/vaultik/config.yml.`,
return
}
if errors.Is(err, vaultik.ErrFilesUnreadable) {
log.Error("Backup completed with unreadable files", "error", err)
if errors.Is(err, vaultik.ErrFilesUnreadable) ||
errors.Is(err, vaultik.ErrSourcePathEmpty) {
log.Error("Backup completed with an error", "error", err)
ReportErrorf("Backup completed, but %v", err)
return
@@ -133,7 +134,8 @@ on Linux), then /etc/vaultik/config.yml.`,
cmd.Flags().BoolVar(&opts.Prune, "prune", false,
"After backup, drop older snapshots of the same name and remove "+
"orphaned blobs older than prune_grace_period (not after a "+
"backup that skipped unreadable files)")
"backup that skipped unreadable files or found a configured "+
"path empty)")
cmd.Flags().StringVar(&opts.KeepNewerThan, "keep-newer-than", "",
"With --prune: keep snapshots newer than this duration "+
"(e.g. 30d, 4w, 6mo, 1y; m is minutes, mo is months) "+