2 Commits
Author SHA1 Message Date
sneak 13cfd47014 Record the defect-hunt fixes in TODO.md Completed Steps (closes #334)
check / check (push) Canceled after 0s
Adds a Completed Steps entry, newest first, for every issue from the
defect hunt (#288) that a merged
fix closed and that had none: the hunt's own issues, those filed while
fixing them, and the owner's ruling on
#239. Each entry was checked
against the code on `next`. Next Step now says the hunt's fixes have
landed.

Judgement call: #332, closed as
covered by the fix for #296, is
named in that entry rather than given its own.
Judgement call: #351, a Docker
build fix filed while gating these fixes, gets an entry.

Model: opus-5-5
2026-10-14 05:54:02 +00:00
clawbot 5b8d84e38a Do not prune after a backup that skipped unreadable files (closes #382)
check / check (push) Canceled after 0s
snapshot create --skip-errors --prune pruned even when files were
skipped, so by default it deleted the previous snapshot of that name,
the only one still holding a file that had become unreadable, along
with blobs only that snapshot referenced.

The prune is now skipped exactly when the run exits non-zero for
entries --skip-errors could not read or list: older snapshots are
kept, a warning says why, and the run still exits non-zero. A file
removed during the run does not count, so such a run still prunes.
The README's --prune text and the flag's help say so.

Model: opus-5-5
2026-10-14 07:44:50 +02:00
5 changed files with 92 additions and 3 deletions

No files matched your search

+5 -1
View File
@@ -297,7 +297,11 @@ value where a list belongs or a word where a number belongs), is refused.
(for crontab)
* `--prune`: After backup, drop older snapshots of each backed-up name and
remove orphaned blobs from remote storage. By default keeps only the latest
snapshot per name; use `--keep-newer-than` for a rolling window.
snapshot per name; use `--keep-newer-than` for a rolling window. When
`--skip-errors` skipped a file or directory it could not read or list, the
prune does not run and every older snapshot is kept, since one of them may
be the only one that holds what was skipped; the command says so and exits
non-zero. A file removed during the backup does not stop the prune.
* `--keep-newer-than <duration>`: With `--prune`, keep snapshots newer than
this duration instead of only the latest (e.g. `4w`, `30d`, `6mo`, `1y`).
Without `--prune`, or with a value that is not a valid duration, the
+10
View File
@@ -24,6 +24,16 @@ owner's; a `v*` tag runs the release build in
# Completed Steps
- 2026-10-14: Stopped `snapshot create --prune` pruning after a backup
that skipped files it could not read
([issue #382](https://git.eeqj.de/sneak/vaultik/issues/382)). Under
`--skip-errors` the prune still ran, so by default it deleted the
previous snapshot of the name, the only one still holding a file that
had become unreadable, along with blobs only that snapshot used. When
`--skip-errors` skipped anything it could not read or list, the prune
now does not run and older snapshots are kept, a warning says why, and
the run still exits non-zero.
- 2026-10-14: Made `snapshot create --skip-errors` exit non-zero when
it skips anything it cannot read
([issue #310](https://git.eeqj.de/sneak/vaultik/issues/310)). The run
+2 -1
View File
@@ -132,7 +132,8 @@ on Linux), then /etc/vaultik/config.yml.`,
"Run in cron mode (silent unless warning or error)")
cmd.Flags().BoolVar(&opts.Prune, "prune", false,
"After backup, drop older snapshots of the same name and remove "+
"orphaned blobs")
"orphaned blobs (not after a backup that skipped unreadable "+
"files)")
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) "+
+9 -1
View File
@@ -153,7 +153,15 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
len(snapshotNames), v.UI.Duration(time.Since(overallStartTime)))
}
if opts.Prune {
switch {
case opts.Prune && filesUnreadable > 0:
// An older snapshot may be the only one that still holds a file
// this run could not read.
log.Warn("Skipping post-backup prune",
"files_unreadable", filesUnreadable)
v.UI.Warningf("Skipped the post-backup prune because %d file(s) "+
"could not be read; older snapshots are kept.", filesUnreadable)
case opts.Prune:
err = v.runPostBackupPrune(snapshotNames, opts.KeepNewerThan)
if err != nil {
return fmt.Errorf("%w: %w", ErrPostBackupPruneFailed, err)
@@ -215,3 +215,69 @@ func TestBackupIgnoresExcludedFileRemovedBeforeWalkReachesIt(t *testing.T) {
assert.Contains(t, env.out.String(), filesLine(1, 1, 0))
assert.Contains(t, env.out.String(), "Finished successfully.")
}
// A backup under --skip-errors --prune that skipped a file it could not
// read does not prune: the previous snapshot, the only one that holds
// the file, is kept, and the output says the prune was skipped. See
// https://git.eeqj.de/sneak/vaultik/issues/382.
func TestSkipErrorsBackupDoesNotPruneWhenItSkips(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
env := newDeletedCountEnv(t, map[string][]byte{
"other.txt": []byte("other"),
"skipped.txt": []byte("skipped"),
})
env.setSnapshot("photos", "")
env.backUp(t, "photos")
// An unchanged file is not read again, so the file is changed before
// it becomes unreadable.
skipped := filepath.Join(env.src, "skipped.txt")
require.NoError(t, os.WriteFile(skipped, []byte("changed"), 0o600))
env.v.Fs = &openFailFs{path: skipped, err: os.ErrPermission}
env.out.Reset()
err := env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
Prune: true,
SkipErrors: true,
Snapshots: []string{"photos"},
})
require.ErrorIs(t, err, vaultik.ErrFilesUnreadable)
assert.Contains(t, env.out.String(),
"Skipped the post-backup prune because 1 file(s) could not be read")
assert.NotContains(t, env.out.String(), "Running post-backup prune.")
snapshots, err := env.v.Repositories.Snapshots.ListRecent(
context.Background(), listRecentTestLimit)
require.NoError(t, err)
assert.Len(t, snapshots, 2, "the previous snapshot is kept")
}
// A file removed during a backup under --skip-errors --prune is not a
// skipped file, so the backup prunes as usual. See
// https://git.eeqj.de/sneak/vaultik/issues/382.
func TestSkipErrorsBackupPrunesWhenAFileIsRemovedDuringIt(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
env := newDeletedCountEnv(t, map[string][]byte{
"other.txt": []byte("other"),
"removed.txt": []byte("removed"),
})
env.setSnapshot("photos", "")
env.backUp(t, "photos")
env.v.Fs = &removeOnLookupFs{path: filepath.Join(env.src, "removed.txt")}
require.NoError(t, env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
Prune: true,
SkipErrors: true,
Snapshots: []string{"photos"},
}))
snapshots, err := env.v.Repositories.Snapshots.ListRecent(
context.Background(), listRecentTestLimit)
require.NoError(t, err)
assert.Len(t, snapshots, 1, "the previous snapshot is pruned")
}