On this page
#Changelog
#0.10.2
Documentation frontmatter converted to TOML for the current selfdoc; no user-facing change.
#Infrastructure
- Documentation frontmatter converted to TOML for the current selfdoc; no user-facing change.
#0.10.1
Version injection is fixed, the project describes itself consistently in its README, package documentation and registries, and its documentation base is the unified site.
#Fixes
- Correct version reporting. Released binaries reported a fallback version instead of the released one, because the linker's
-Xtarget named a symbol that did not exist. - The project describes itself consistently in its README, package documentation and registries. Each surface carried its own wording or none at all -- the Go package published no synopsis, the PyPI page no description, and neither registry entry carried the project's links or discovery keywords.
- Documentation links point at the unified site. The declared docs base was the retired per-project host; it is
https://smmh.dev/saferm/now, so generated sitemaps, feeds and llms.txt name the address that serves the pages.
#0.10.0
Every flag and argument declares whether it must be given, purge's selection rule becomes a declaration the parser enforces, and the machine envelope advances to version 2.
Context
saferm builds against a CLI framework release that refuses a flag whose presence is unstated, and refuses a value default on any flag or argument of a command that changes things -- because on such a command a value the framework picked is a value the framework writes. Every switch on delete, undelete and purge therefore declares itself optional and names its fallback in its own help text, and behaviour on absence is unchanged throughout.
The same release makes co-occurrence rules declarable, which is what retires purge's hand-written selection check. The rule that at least one of the target identifiers, --older-than, --larger-than or --all must be given now lives in one place: the parser enforces it, --help renders it, and the dumped schema publishes it. The cost is a different refusal sentence and a different exit code, both of which are in the changelog rather than left to be discovered by a script that reads them.
Three consumer-visible spellings move with the framework: the envelope's interface_version, the writes member beside it, and config set's argv. None of the invocation shapes programs already build -- delete with --on-error, -r, -f, --description; undelete, list, info, capabilities -- changed at all.
#Breaking
- **
purgerefuses an unselected run with a different sentence, and exits 1 instead of 2.** The rule that at least one of the target identifiers,--older-than,--larger-thanor--allmust be given is now declared to the CLI framework instead of checked inside the handler, so the parser refuses the command line before anything runs:error: constraint "purge-selection": at least one of targets, --older-than, --larger-than, --all is required, exit 1. The old sentence (error: specify record UUIDs or numeric IDs, --older-than, --larger-than, or --all, exit 2) is gone. A script matching on either the text or the exit code must be updated.--no-allnow selects nothing and says so rather than being read as a selection, andsaferm purge --helpshows the rule in aConstraints:block. - **The machine envelope declares
interface_version: 2.** Machine mode (--json) is the CLI framework's, and the framework's envelope contract advanced with the release saferm now builds against. The document gains awritesmember -- alwaysnullhere, since saferm declares no update command -- and the version it reports is 2. A consumer that pinsinterface_version == 1must be updated before it can read saferm's output; one that readspayloadand ignores the version needs no change. - **
saferm config settakes--value, and the trailing positional value is gone.** The config commands come from the CLI framework, whoseconfig setnow spells its three outcomes as one required choice:saferm config set <key> --value <v>,saferm config set <key> --clear,saferm config set <key> --default. The oldsaferm config set archive_dir /mnt/backupform is refused, and--defaultand--clearfollow the key rather than preceding it.config show,config pathandconfig editare unchanged.
#Features
- **Every flag and every argument now states whether it must be given, and
--helpsays so.** Each line insaferm <command> --helpends in[required],[optional]or[default: v], positional arguments included, and the two closed-value flags describe each of their values:--on-error'sabortandcontinue,--on-conflict'soverwriteandabort. Nothing behaves differently when a flag is omitted ---rstill opts in,--update-git-indexstill updates the index by default, an omitted--commandstill records nothing -- but the switches ondelete,undeleteandpurgeno longer carry a default value at all, because the framework forbids one on a command that changes things; each names its fallback in its own help text instead. The published schema (saferm --dump-schema) carries apresencefield on every flag and argument andpurge's selection rule in full.
#0.9.0
saferm gains a machine-output surface -- the strictcli envelope on delete, undelete, list and info plus a capabilities verb -- alongside a bounded contention retry with its own exit code, durable uuid handles, a mandatory delete error-mode and restore conflict-mode, a restore that verifies the archived copy before it overwrites, and origin columns derived from the process trace store.
Context
This release is about saferm being driven by a program rather than read by a person.
Everything a run produced now has a structured form: delete, undelete, list and info declare a payload schema and supply their value inside the framework's envelope under --json, so stdout carries exactly one document a caller can parse -- including the paths a batch could not archive and why. purge deliberately declares no payload, because nothing should drive the irreversible operation from a parsed document. saferm capabilities is the probe that makes the surface negotiable by feature name instead of by version string, which a locally built saferm cannot supply in a form any semver parser accepts.
The decisions a caller must state are now stated. delete --on-error and undelete --on-conflict have no defaults, because a batch that meets a bad path and a restore that meets an occupied destination each have two defensible answers that suit opposite callers. --on-conflict replaces --force-overwrite, and an overwrite verifies the archived copy against the record before the destination is touched, so a corrupt copy refuses instead of destroying what stands there. Record uuids are printed and accepted everywhere an id is, so a handle survives a database whose numbering it does not control. Database contention is retried on a bounded budget and, when the budget runs out, exits 8 rather than the generic database code -- "try again" and "the archive is broken" are different answers.
Origin columns are derived, never declared: saferm resolves which tool ran a deletion from the strictcli process trace store rather than trusting a flag, and embeds the resolved ancestry in the record so pruning the store cannot orphan it.
The framework dependency moved off the local workspace checkout onto released go-strictcli v0.32.0, which publishes the machine-mode API this surface is built on: PayloadSchema, ctx.Payload, and the envelope. That framework release also reworded the refusal an unapproved consequential command gives on a non-terminal stdin, which saferm inherits and documents.
A launcher that delegates its deletions to saferm drives this version's machine surface, so the payload shapes and the capabilities verb are what it negotiates against.
#Breaking
- **
saferm deletenow requires--on-error abortor--on-error continue.** The flag is mandatory and has no default: a batch that meets a bad path can stop there or archive the rest, and the two suit opposite callers, so saferm refuses to pick one silently.abortstops at the first failing path;continuearchives the remaining paths, reports every failure, and exits at the end with the first failure's code. Existing invocations ofdeletemust be updated -- omitting the flag is an argument error. Whichever mode is chosen, the identifiers of everything already archived are printed before the failure is reported. - **
undelete --force-overwriteis replaced by--on-conflict overwrite|abort.** A destination that is already occupied now requires an explicit answer with no default, the same shapedelete --on-errortakes; omitting it is an argument error naming both values. An empty directory standing where a tree was archived is no longer treated as a conflict, so restoring a tree into its own emptied location needs no flag. - **The refusal an unapproved
purgeprints has been reworded.** Runningsaferm purgewithout--approve-consequentialwhere stdin is not a terminal now fails witherror: stdin is not interactive; a consequential command must be confirmed at a terminal, replacing the older text that named the flag. Consent itself is unchanged --saferm --approve-consequential purge ...works exactly as before -- but a script matching the old wording on stderr must be updated. The message comes from the CLI framework, which saferm now builds against at its released version.
#Features
- The archive uuid is now a handle you can hold.
saferm deleteprints one line per archived path carrying both the record's numeric id and its uuid (archived: [12] <uuid> /path (4 B)), andundelete,infoandpurgeall accept a uuid as well as an id. An identifier argument is read by shape in a fixed order -- a 36-character hyphenated hex string is a uuid, an all-digit string is an id, anything else is a path -- andinfonow states in one line whether a record isrestorable,restored at <time>,purged at <time>, or both. - Deletions now record which tool ran them. Two new columns,
origin_nameandorigin_version, are filled from the strictcli process trace store: saferm readsSTRICTCLI_TRACE_PARENTfrom its own environment, resolves that entry, and takes the caller's declared name and version. There is no flag to state an origin and nothing is inferred from anything else -- null means no tool claimed the deletion, which is what every existing row and every deletion from a shell stays. The full ancestry chain is resolved at capture time and embedded in the record's metadata, so pruning the trace store can never orphan a record, and a polluted variable, a pruned store or a parent that resolves to nothing is recorded as an anomaly instead of failing the deletion. Every delete invocation also stamps onegroup_idon every record it writes. - **
undelete --destination <path>restores somewhere other than the original location.** The path is recorded on the record, sosaferm infonames where the content actually went, and the conflict rules apply to the destination you named. - **
undelete --no-update-git-indexskips staging the restored path.** The restore used to rungit addunconditionally; the switch mirrorsdelete --update-git-index, so a caller can turn the index side effects off on both halves of the round trip. - **A machine surface:
--jsonanswers with one JSON document per run.**delete,undelete,listandinfoeach carry a structured payload inside the envelope -- the records a delete wrote with both identifiers and the invocation's group id plus every path it could not archive and the reason why, where a restore put the content and whether it replaced anything, the rows of a listing with their uuids and absolute timestamps, and the full record with its status word, origin and group. Everything saferm would have printed rides inside the same document, so stdout carries exactly one thing a program can parse.purgedeliberately answers with no payload. Nothing changes outside--json. - **
saferm capabilitiesnames the features this binary ships.** A program deciding how to drive saferm asks for a feature by name instead of comparing version strings -- a locally built saferm reports a Go pseudo-version no semver parser accepts. The verb reads nothing at all, so it answers on a machine where saferm has never run and creates no state to do it; a missing verb and a missing feature mean the same thing to a caller. - A machine-surface specification page. The docs now carry the envelope reference, each verb's payload shape, the capabilities contract, and where the payload schemas are published verbatim (
--dump-schema).
#Fixes
- Restoring an already-restored record now reports its status.
saferm undeleteon a record that was already restored used to fail with a raw archive-layer error naming an internal UUID, or advertise--force-overwriteas a remedy that could not have worked. It now says when and where the record was restored, before touching anything. - **
saferm list --pathnow matches nested paths.** Its*stopped at a directory separator, so a pattern like/home/m/*could only ever match direct children -- and saferm records absolute paths, which are always deeper than that.*now spans separators, and the documented example is corrected. - A redaction pattern that does not compile now fails the command. A typo in
exclude_env_patterns(or--exclude-env-patterns) used to be dropped in silence, so the environment variables it was meant to keep out of the archive were captured instead. saferm now refuses to run and names the offending pattern. - The documented redaction example now works. The sample
exclude_env_patternslist showed(?i)key(?!BOARD), which Go's RE2 engine cannot compile -- anyone who copied it lost theirkeyredaction. The example is corrected and the docs now state the RE2 limits. - Database contention is retried. A deletion that meets a locked archive database now retries automatically (five attempts, short backoff) instead of failing with the raw SQLite error, and reports each retry under
--verbose. Contention that outlives the retry budget exits with the new code 8, distinct from the generic database code 5, so a caller can tell "try again" from "the archive is broken". The documentation previously claimed this safety without providing it. - A deletion whose database record fails no longer strands the file. Archiving used to happen before the record was written, so an insert that failed left the archived copy on disk, the original gone, and nothing naming either -- the file was unreachable by every saferm command. The order is now archive, record, then remove the original: a failed record discards the archived copy and leaves the path exactly where it was, and a regular file is hard-linked into the archive so nothing is copied in the meantime.
- A failed restore no longer leaves a half-restored destination. A directory restore that met a truncated or corrupted archive used to stop partway, leaving part of the tree at the destination; it now takes that partial extraction back, names what it had extracted, and keeps the archived copy so the record stays restorable. Every restore now drops the archived copy only as its last act, so a failure of any kind can simply be run again.
- An overwriting restore checks the archived copy before it destroys the destination.
saferm undeleteused to remove whatever stood at the destination and only then read the archive, so a corrupted or truncated copy cost you the file that was standing there. The copy is now verified first and a mismatch refuses the restore without touching the destination. What each kind's check proves is documented: a file's hash covers its content exactly, a directory's covers the .tar.zst container, and a symlink's entry is compared against the recorded target. - A symlink replaced after it was archived is no longer deleted.
saferm deletere-checks that a path is still what it archived before removing it, and for a symlink that check was the inode number alone -- which the filesystem hands straight back to a replacement, so a link written over the archived one in the meantime was removed with nothing holding a copy of it. The recorded target is now compared as well. Only ext4 and other inode-reusing filesystems were affected; a tmpfs never reuses a number and never showed it.
#0.8.1
Publish-workflow fix so the npm wrapper stops racing the binary upload.
Context
0.8.0's publish job for the npm wrapper failed and had to be retried. The job runs npm ci, which runs the wrapper's own postinstall, which downloads the platform binary from the GitHub Release for the version in package.json -- an asset the goreleaser job in the same workflow was still uploading. The retry passed only because by then it had finished, so whether a release publishes to npm was decided by a race.
npm ci --ignore-scripts removes the race outright: the wrapper has no dependencies, and running a package's install hooks was never part of preparing it for publication. Infrastructure only -- no code, no behaviour, nothing a 0.8.0 user needs to read.
#Infrastructure
- Publish-workflow fix so the npm wrapper stops racing the binary upload.
#0.8.0
One consent gate on purge, a --quiet flag that works, and a dry run that no longer creates the directory it promises not to touch.
Context
saferm shipped 0.7.1 with two consent gates stacked on purge: the CLI framework's consequential approval, and saferm's own [y/N] prompt behind it. An approved non-interactive saferm --approve-consequential purge --all reached the second gate, read EOF, and aborted -- one operation asking twice, with the second ask unanswerable exactly where the first had already been given. The prompt and its --skip-confirmation/-f escape hatch are gone. What the prompt was actually for survives it: the per-record listing of everything about to be destroyed now prints unconditionally after consent and before the first removal.
Two other flags stopped lying in the same pass. --quiet was accepted and ignored -- saferm read it nowhere -- and now suppresses summaries and progress while never touching the outputs that ARE the command: list, info, the dry-run previews, and purge's destruction listing. And --dry-run used to create saferm's whole state directory on a fresh machine on its way to promising it would touch nothing; that creation is now minted on the effects handle, so it is named in the would-do log and performed only in a real run. Following that thread turned up its mirror image: the read-only commands, which correctly create nothing, answered "what have I deleted?" on a fresh machine with SQLite's "unable to open database file". An absent archive is an empty one, and they say so now.
Two entries in this release's changelog described designs that were superseded before they ever shipped -- a blanket approval flag and a prompt in front of every destructive command -- and were rewritten to the end state that ships.
#Breaking
- **Breaking:
--quiet,--verbose,--dry-runand--approve-consequentialare the CLI framework's flags now.** They are recognized anywhere on the command line and have no short forms. saferm's own--verboseglobal andpurge's own--dry-runare gone: both spellings still work, they are just delivered by the framework, and--dry-runnow applies to every command instead of only topurge. Every command also carries an effect classification --delete,undeleteandpurgeare mutating,listandinfoare read-only. - **Breaking:
purgeis the only command that asks before it runs, and--approve-consequentialis the only way to say yes.**saferm deleteandsaferm undeleterun bare -- no flag, no prompt -- because both are recoverable: delete moves a file into the archive and undelete brings it back. Confirmation is reserved forpurge, the one operation with no way back. A non-interactive purge issaferm --approve-consequential purge --all; without the flag it refuses witherror: stdin is not interactive; pass --approve-consequential to confirm. - **Breaking:
purge --skip-confirmation(-f) is gone.**purgeused to raise its own[y/N]prompt behind the framework's approval gate, so one operation asked for consent twice -- and the second ask was unanswerable exactly where the first had already been given: an approvedsaferm --approve-consequential purge --allread EOF from it and aborted.--approve-consequentialis now the single consent. The listing of every record about to be destroyed survives the prompt that carried it: it prints unconditionally after consent and before the first removal, and--quietdoes not suppress it.
#Features
- **
--dry-runworks on every command and records what it would destroy.** It used to exist only onpurge. A dry run now prints a would-do log naming each path it would move, archive or permanently remove -- and moves, archives and removes nothing.saferm --dry-run delete,--dry-run undeleteand--dry-run purgeare all previews you can trust. - The documentation site gained an architecture page and a configuration guide. The architecture page walks the archival, restore and purge paths and the database schema; the configuration guide covers
~/.saferm/config.toml,SAFERM_HOMEand the metadata denylist. The exit-code table is now rendered from one source instead of hand-maintained prose, and the pages carry the descriptions and titles that search engines were warning about. - **Every flag and argument in
--helpnow says what it is for.** The one-word help strings (Recursive deletion,Filter by path) are gone; each flag, argument and command carries a sentence explaining what it does and when to reach for it. - **
SAFERM_HOMEis now a declared infrastructure root.** It appears in--helpunder its ownInfrastructure:section, and is explicitly exempt from--hermetic: it selects where saferm lives, which is a different question from the config values that select how saferm behaves. - **
--quietnow actually silences saferm.** The framework-owned flag was accepted and ignored:saferm --quiet deletestill printed its summary. It now suppresses the counted summaries, the--verboseper-item progress,Nothing to purge.and theRestored <path>confirmation, and it dominates--verbosewhen both are passed. It never suppresses what you asked for:listandinfooutput, the--dry-runpreviews and would-do log,purge's listing of what it is destroying, and everything on stderr.
#Fixes
- **Docs no longer show the retired
undelete --forcespelling.** The flag is--force-overwrite; the old form was a parse error exactly when someone was trying to recover a file. - A dry run no longer creates saferm's state directory.
saferm --dry-run deleteon a machine with no archive yet made~/.saferm/,~/.saferm/archive/and~/.saferm/db/on its way to promising it would touch nothing, and never mentioned them. The creation is now declared like every other mutation: named in the would-do log, performed only in a real run. - **
saferm liston a machine that has never deleted anything now says so.** It exited 5 witherror: opening database: unable to open database file-- SQLite's answer to a missing file, offered as the answer to "what have I deleted?". An absent archive is an empty one:listreports no archived items and exits 0, andinforeports no such record and exits 3.
#0.7.1
Fix Windows build break so releases publish for all platforms; correct config-conflict docs.
Context
v0.6.2 and v0.7.0 Publish workflows failed because goreleaser builds Windows but readParentCmdline was defined only under linux/darwin build tags, leaving it undefined on windows. Added a non-linux/darwin stub returning an honest empty (unavailable) value. Also corrected the config-conflict documentation to note that the CLI/config divergence check only fires for pre-command global flag placement, not post-command.
#Fixes
- Windows binaries build again. Fixed a build break (undefined
readParentCmdline) that prevented Windows binaries from compiling, so releases now publish for all platforms. - Corrected config-conflict docs. The CLI/config divergence protection only applies when a global flag is given in the pre-command position; the docs now state this caveat instead of implying it always applies.
#0.7.0
Adopt strictcli v0.21.0 config safety: malformed config.toml is now a hard error with parse position (was a silent warning), unknown config keys are rejected, and diverging CLI/config values for --archive-dir/--db-path conflict instead of silently letting the CLI win.
Context
saferm previously pinned strictcli v0.17.0, where a broken config.toml printed a warning and ran with default paths -- silent degradation that could send deletions to the wrong archive without the user noticing. This release bumps to strictcli v0.21.0 and deliberately adopts its config-safety primitives:
- Malformed config is a hard error (exit 1) with parse position. This is the one
behavior change that arrives with the bump; it is announced as breaking because a config that previously "worked" (silently ignored) now fails loudly.
- ConfigFields for archive_dir/db_path enable unknown-key rejection (catches typos
like archve_dir that were silently dropped).
- Per-flag ConflictMode("error") on archive-dir/db-path makes a diverging CLI vs
config value a conflict; identical values are still accepted (divergence-aware). exclude-env-patterns stays cli-wins (it is a list; ConfigField supports scalars only).
Config-layer failures exit 1 (strictcli's layer); saferm's own semantic conflicts remain exit 7. SAFERM_HOME stays infrastructure, not config, and is not suppressed by --hermetic.
#Breaking
- **Malformed
config.tomlis now a hard error.** A syntactically invalid~/.saferm/config.tomlpreviously printed a warning and silently ran with default archive/database paths. It now fails immediately (exit 1) reporting the parse position, so a broken config can never be ignored.
#Features
- **Conflicting CLI and config values for
--archive-dir/--db-pathnow error.** When a CLI flag value diverges from the value set inconfig.toml, saferm reports a conflict (exit 1) instead of silently letting the CLI win. Identical values are accepted.
#Fixes
- Unknown config keys are rejected. A typo'd or unrecognized key in
config.toml(for examplearchve_dir) is now a hard error instead of being silently ignored, surfacing misconfiguration that previously left settings unapplied.
#0.6.2
CI fix: generate missing pypi/uv.lock
#Infrastructure
- CI fix: generate missing pypi/uv.lock
#0.6.1
Fix CI workflow for PyPI wrapper package
#Infrastructure
- Fix CI workflow for PyPI wrapper package
#0.6.0
Purge preserves metadata audit trail
#Breaking
- Breaking. Migrate to strictcli v0.17.0: rename flags and add explicit bool defaults.
#Features
- New feature. Purge now keeps the metadata record (audit trail) and only deletes archived content. Purged items appear in list --all with 'purged' status.
#0.5.0
Purge size filtering and dry-run preview
#Features
- New feature.
saferm purge --larger-than SIZEfilters by file size (e.g.100MB,1GB).saferm purge --dry-runpreviews what would be purged without deleting.
#0.4.0
Config migration to strictcli
Context
Config is now managed by strictcli's built-in config system instead of a hand-rolled TOML loader. The config file stays at ~/.saferm/config.toml with the same keys, but you get config show/set/path/edit subcommands for free. The internal/config package is removed.
#Breaking
- Breaking. Removed
internal/configpackage. Config is now handled entirely by strictcli flags (--archive-dir,--db-path,--exclude-env-patterns).
#Features
- New feature. Config is now managed by strictcli's built-in config system. New subcommands:
saferm config show,saferm config set,saferm config path,saferm config edit. Config file remains at~/.saferm/config.tomlwith the same keys. - Upgrade. strictcli framework upgraded to v0.12.1 (array config support, repeatable flag defaults fix).
#0.3.0
Symlink support and selfdoc root file templates
Context
Standalone symlinks are now archived correctly instead of following the link target. Previously, archiving a symlink would hash and store the target's content (or fail entirely for dangling symlinks). Now saferm preserves the symlink target path and recreates it on restore. Also adopted selfdoc templates for CLAUDE.md and README.md.
#Features
- New feature. Symlinks are now archived by preserving the link target path instead of following the symlink. Dangling symlinks, relative symlinks, and symlinks to directories are all handled correctly. Restore recreates the original symlink.
- New feature.
saferm listnow shows type indicators:[sym]for symlinks,[dir]for directories. - New feature.
saferm infonow shows symlink type and target path for symlink deletions.
#Fixes
- Fix.
saferm purgenow correctly removes.symlinkmetadata files from the archive.
#0.2.1
Dependency upgrades and housekeeping
#Features
- Upgrade. strictcli framework upgraded to v0.11.0.
#0.2.0
Automatic git index management. When deleting a git-tracked file, saferm now runs git rm --cached to stage the removal. When restoring via undelete, it runs git add. Use --no-git on delete to suppress. Both operations are best-effort -- failures produce warnings, not errors.
#Features
- Automatic git index management. When deleting a git-tracked file, saferm now runs
git rm --cachedto stage the removal. When restoring viaundelete, it runsgit add. Use--no-giton delete to suppress. Both operations are best-effort -- failures produce warnings, not errors.
#0.1.3
No user-facing changes.
- No user-facing changes.
#0.1.2
New feature. npm and PyPI binary wrapper packages for easier installation via npm install -g saferm and pipx install saferm.
#Features
- New feature. npm and PyPI binary wrapper packages for easier installation via
npm install -g safermandpipx install saferm.
#0.1.1
New feature. SAFERM_HOME environment variable allows overriding the default config/data directory, enabling isolated testing and custom configurations.
#Features
- New feature. SAFERM_HOME environment variable allows overriding the default config/data directory, enabling isolated testing and custom configurations.
#0.1.0
Feature. Safe file deletion with automatic archival
#Features
- Feature. Safe file deletion with automatic archival
- Feature. Undelete command to restore archived files
- Feature. List, purge, and info commands for archive management
- Feature. Rich metadata capture: environment, git context, parent process
- Feature. SQLite database with WAL mode for concurrency safety
- Feature. Tar+zstd compression for archived directories
- Feature. Configurable environment variable denylist