Skip to content
saferm
Edit
On this page

#saferm

AI-first safe rm replacement. Archives files instead of deleting them.

#CLI Reference

#Packages

saferm is organized into internal packages that handle distinct responsibilities: file and directory archival with integrity verification, SQLite-based metadata storage, git context detection, environment and process metadata capture, and reading the shared process trace store that answers which tool ran a deletion. Each package is independently testable and designed for concurrent use via WAL-mode SQLite and atomic file operations. Configuration is handled by strictcli's built-in config system (TOML format at ~/.saferm/config.toml).

#internal/archive

Package archive handles file and directory archival to the saferm archive. It supports same-filesystem hard links, copy-and-verify wherever a link is refused, and tar+zstd compression for directories.

Archival is two calls, not one: [Execute] writes the archive entry and leaves the original alone, [RemoveSource] removes the original afterwards, and [DiscardBlob] takes the entry back. The gap between them is where a caller records the deletion, so an archive entry never exists without a way to find it.

#KindFile

Go go
const KindFile Kind = iota

The three shapes an archived entry takes on disk.

#KindDirectory

Go go
const KindDirectory
Go go
const KindSymlink

#ErrFileNotFound

Go go
var ErrFileNotFound      = errors.New("file not found")

Sentinel errors.

#ErrRecursiveRequired

Go go
var ErrRecursiveRequired = errors.New("target is a directory; recursive flag required")

#ErrHashMismatch

Go go
var ErrHashMismatch      = errors.New("hash mismatch after copy")

#ErrEntryMissing

Go go
var ErrEntryMissing  = errors.New("the archived copy is not in the archive")

What a restore can find wrong with an archived copy before it touches anything at the destination. See [VerifyEntry] for what each kind's hash does and does not prove.

#ErrEntryCorrupt

Go go
var ErrEntryCorrupt  = errors.New("the archived copy is not what the record says it is")

#ErrEntryDiverged

Go go
var ErrEntryDiverged = errors.New("the archived symlink entry does not name the target the record names")

#ErrUnverifiable

Go go
var ErrUnverifiable  = errors.New("the record carries no hash, so the archived copy cannot be checked before the destination is destroyed")

#ErrSourceReplaced

Go go
var ErrSourceReplaced         = errors.New("the source path no longer names the file that was archived")

The ways a source can stop being what was archived while the caller is recording the deletion. See [RemoveSource] for why that window is wide enough to matter and what each of these means for the record.

#ErrArchiveEntryMissing

Go go
var ErrArchiveEntryMissing    = errors.New("the archived copy is gone, so nothing holds the content the removal would destroy")

#ErrArchiveEntryReplaced

Go go
var ErrArchiveEntryReplaced   = errors.New("the archive entry is no longer the file that was archived")

#ErrSourceDiverged

Go go
var ErrSourceDiverged         = errors.New("the source changed after it was hashed, and the archive holds an independent copy of the older content")

#ErrArchivedContentChanged

Go go
var ErrArchivedContentChanged = errors.New("the source was written through while its archive entry was a link to it, so the recorded hash no longer describes the archived bytes")

#ErrDirectoryChanged

Go go
var ErrDirectoryChanged       = errors.New("the tree changed after it was archived, so the archive does not hold everything the removal would destroy")

#ErrNotExecuted

Go go
var ErrNotExecuted            = errors.New("the plan was never executed, so there is nothing to check the source against")

#ArchiveResult

Go go
type ArchiveResult struct

ArchiveResult holds the outcome of archiving a file or directory.

#Kind

Go go
type Kind int

Kind names what an archival is about to move.

#Plan

Go go
type Plan struct

Plan is everything an archival can determine by reading: what the entry is, where it will land, and (for a symlink) what it points at. Building one mutates nothing, so a caller can render a plan as a preview and stop, or hand it to [Execute] and go through with it.

#RestorePlan

Go go
type RestorePlan struct

RestorePlan is everything a restore can determine by reading: which archive entry holds the record's content, what shape that entry is, and where it is going. Building one mutates nothing.

A restore is split the way an archival is, and for the same reason: the acts that consume the archived copy must be separable from the acts that write the destination, so that a failure can always leave the copy where it is. Every primitive below is one of those halves, and none of them decides anything -- what to do about a destination that already exists, and whether the entry is verified first, are the caller's decisions.

#NewPlan

Go go
func NewPlan(path string, archiveDir string, isRecursive bool) (*Plan, error)

NewPlan inspects path and resolves where archiving it would put it. It performs no mutation.

#Execute

Go go
func Execute(p *Plan) (*ArchiveResult, error)

Execute writes the archive entry a [Plan] describes, and LEAVES THE SOURCE WHERE IT IS.

Archiving is deliberately split from removing the original, because between the two there is a third party: the caller's database, which is what makes an archived entry findable at all. Doing it in one step meant an archive that succeeded and a record that failed produced a blob nobody could name, a source path that was gone, and no way back -- and the live archive really did collect orphaned blobs that way. So the order is Execute, record, then [RemoveSource]; a record that fails calls [DiscardBlob] instead and the caller's file has never been touched.

For a regular file the entry is a hard link to the original, which is why leaving the source in place costs nothing: the content is not copied and both names point at the same inode until RemoveSource drops one of them.

#RemoveSource

Go go
func RemoveSource(p *Plan) error

RemoveSource removes the original an executed [Plan] archived. It is the second half of an archival and runs only once the entry is recorded.

It removes by identity, not by name, and only once it has seen the archived copy. Between [Execute] and this call sits the caller's database insert, and that is not an instant: a contended SQLite write retries for tens of seconds, and both the source path and the archive entry are live filesystem paths the whole time. Three things can happen in there, and removing whatever the name happens to resolve to gets all three wrong:

- The path can be REPLACED -- renamed over, or removed and recreated. The archive holds the original; the name now leads somewhere else, and removing it would destroy a file nothing archived. - A regular file's archive entry is a hard link, so a write THROUGH the path mutates the archived bytes. The recorded hash then describes content that no longer exists anywhere, and removing the source would leave that record standing over a blob it does not match. - The ENTRY can go, or stop being the archived thing. The row is inserted before this runs, so a concurrent purge can select it and destroy its blob perfectly legitimately; removing the source afterwards leaves no copy of the content anywhere at all.

So both sides are re-checked first and the removal is refused on any mismatch, with [ErrSourceReplaced], [ErrSourceDiverged], [ErrArchivedContentChanged], [ErrArchiveEntryMissing] or [ErrArchiveEntryReplaced] naming which one the caller is holding. Nothing is undone here: refusing to remove is the whole of the remedy this half can apply, and what to do about the record is the recording caller's decision.

#NamePaths

Go go
func NamePaths(paths []string) string

NamePaths renders the paths a refusal is about. All of them up to a handful, because the caller has to go and look at them, and a count after that so a tree that changed wholesale does not print itself into the terminal.

#DiscardBlob

Go go
func DiscardBlob(p *Plan) error

DiscardBlob removes the archive entry [Execute] wrote, undoing it. The source is untouched by both calls, so a discarded archival leaves the filesystem exactly as it was.

#NewRestorePlan

Go go
func NewRestorePlan(uuid string, archiveDir string, dest string, isDirectory bool, symlinkTarget string) *RestorePlan

NewRestorePlan resolves where a record's content lives and where it is going. The kind is read off the record, not off the archive: a record knows whether it archived a tree and what a symlink pointed at, and both facts must be available before anything is read from disk.

#EntryPresent

Go go
func EntryPresent(p *RestorePlan) error

EntryPresent reports whether the archived copy is there to be restored at all. It is a stat, not a read: every restore makes this check, including the ones that deliberately do no verification, because an absent entry is worth naming as such rather than surfacing as a failed rename of a UUID.

#VerifyEntry

Go go
func VerifyEntry(p *RestorePlan, recordedHash string) error

VerifyEntry checks the archived copy against what the record says about it, reading only -- so a caller can refuse a destructive restore BEFORE the destination is touched.

The recorded hash means three different things, one per kind, and this is the only place that states all three honestly:

- KindFile: recordedHash is the SHA-256 of the archived file's CONTENT, and the entry is that file. The check is exact: a byte that rotted in the archive is found here. - KindDirectory: recordedHash is the SHA-256 of the .tar.zst CONTAINER, not of any member and not of the tree. So this proves the container arrived intact -- which is what a corrupt or truncated archive fails -- and says nothing about individual members beyond that. There is no per-member digest anywhere in the archive, so no check here can promise one. - KindSymlink: nothing was hashed at all. A symlink has no content; its entry is the recorded target written out, and recordedHash is empty by construction. The check is therefore an equality: the entry must still name the target the record names. A hash comparison here would fail spuriously on every symlink ever archived.

A file or a tree whose record carries no hash cannot be verified at all, and that is [ErrUnverifiable] rather than a pass: the caller asked to destroy a destination on the strength of a check that cannot be made.

Go go
func RestoreSymlink(p *RestorePlan) error

RestoreSymlink recreates the link at the plan's destination. It does NOT consume the entry: the caller removes it once the link is there, so a failure leaves the recorded target on disk.

#ExtractTree

Go go
func ExtractTree(p *RestorePlan) ([]string, error)

ExtractTree extracts the tree held in the plan's entry into its destination and does NOT consume the entry, for the same reason as [RestoreSymlink]: an extraction that fails partway must leave the archived copy readable, so the restore can simply be run again.

It returns every path it created, in creation order, whether it succeeded or not -- that list is what makes a partial extraction reportable and undoable.

#RollbackExtraction

Go go
func RollbackExtraction(created []string) []string

RollbackExtraction removes the paths a failed extraction created, newest first, and returns the ones it could not remove.

The destination of a restore holds nothing but archive-derived bytes: it was absent, or an empty directory, or removed outright by an overwrite that verified the archived copy first. So undoing a partial extraction destroys nothing that is not still in the archive -- the entry is consumed only after the extraction succeeds. Leaving the half tree instead would leave a destination that looks restored and is not, and a retry would then meet its own leftovers as a conflict.

Directories go through os.Remove, not os.RemoveAll: reverse order empties them first, and one that is still not empty holds something this extraction did not write. That is exactly what must survive, so it is reported as stuck rather than destroyed.

#CopyOut

Go go
func CopyOut(src string, dst string) error

CopyOut is the cross-device half of a file restore: the archived copy is copied to dst and consumed only once the copy is complete.

A rename cannot cross a filesystem boundary, and the archive and the destination are not always on one. The order is what keeps the failure safe: a copy that fails leaves the entry untouched and takes its own partial destination back, so nothing is left looking restored and the restore can be run again.

#IsCrossDeviceError

Go go
func IsCrossDeviceError(err error) bool

IsCrossDeviceError reports whether a failed rename means "these two paths are on different filesystems", which is what sends a file restore through [CopyOut].

#NewUUID

Go go
func NewUUID() string

NewUUID returns a UUID v4 string using crypto/rand.

It names an archive entry, and it is also what mints the group identifier a delete invocation stamps on every record it writes: both are opaque handles minted with no coordination between processes, so they are the same thing.

#internal/db

Package db manages the SQLite database tracking all saferm deletions.

Concurrency safety across simultaneous sessions comes in three layers: WAL mode, SQLite's own busy_timeout, and a bounded retry on top of both -- every operation that meets SQLITE_BUSY or SQLITE_LOCKED is run again, up to five attempts with a 50ms linear backoff, reported through a RetryNotifier. Contention that outlives the whole budget is returned as a *ContentionError, a type distinct from every other database failure, so a caller can tell "another process holds the write lock, try again" from "this archive is broken".

#SchemaSQL

Go go
const SchemaSQL = `

#ErrNotFound

Go go
var ErrNotFound = errors.New("record not found")

ErrNotFound is returned when a queried record does not exist.

#ErrOriginVersionWithoutName

Go go
var ErrOriginVersionWithoutName = errors.New("origin_version requires origin_name")

ErrOriginVersionWithoutName is returned by Insert when a record carries an origin version but no origin name.

SQLite cannot add a CHECK constraint to an existing table, so the invariant is enforced here instead of in the schema: one enforcement path, identical on a fresh database and on one that reached this schema through the migration ladder, and no table rebuild. The cost is stated rather than hidden -- a hand-edited database, or an older binary writing into a newer one, bypasses it.

#ErrOriginEmpty

Go go
var ErrOriginEmpty = errors.New("origin fields must be absent or non-empty")

ErrOriginEmpty is returned by Insert when an origin field is present but empty. Both fields are nullable and never empty: an empty string would be a third state beside "a tool claimed this" and "none did", and nothing can read it as either.

#RetryNotifier

Go go
type RetryNotifier func(attempt, maxAttempts int, delay time.Duration, err error)

RetryNotifier is called once before each contention retry, so a caller can report the wait under --verbose. It is never called for a failure that is not contention, and never for the final attempt (there is no wait after it).

#ContentionError

Go go
type ContentionError struct

ContentionError reports that an operation was still meeting a locked database after the whole retry budget was spent. It is deliberately its own type: a caller that collapses it into a generic database failure loses the one piece of information that distinguishes "another process is busy, try later" from "this database is broken".

#DB

Go go
type DB struct

DB wraps a *sql.DB connection to the saferm SQLite database.

#DeletionRecord

Go go
type DeletionRecord struct

DeletionRecord represents a single archived deletion in the database.

#IsContention

Go go
func IsContention(err error) bool

IsContention reports whether err is SQLITE_BUSY/SQLITE_LOCKED-class contention -- a lock held by another connection, which retrying can clear.

The driver reports a result code alongside the message, so the classification reads that code rather than matching on English. SQLite's extended codes carry the primary code in their low byte (SQLITE_BUSY_SNAPSHOT is SQLITE_BUSY | (2 << 8), and so on), so the low byte is what is compared -- every extended flavour of BUSY and LOCKED classifies with its primary.

#IsContentionExhausted

Go go
func IsContentionExhausted(err error) bool

IsContentionExhausted reports whether err is a ContentionError -- contention that outlived the retry budget. It is what maps a database failure onto saferm's distinct contention exit code.

#Open

Go go
func Open(dbPath string, notify RetryNotifier) (*DB, error)

Open opens (or creates) the SQLite database at dbPath with WAL mode and busy_timeout, then runs the schema DDL.

notify, when non-nil, is called before each contention retry -- for every operation on the returned DB as well as for the schema work below, which is why it is supplied here rather than set afterwards. Pass nil for no reporting.

#ContentionError.Error

Go go
func (e *ContentionError) Error() string

#ContentionError.Unwrap

Go go
func (e *ContentionError) Unwrap() error { return e.Err }

#DB.Close

Go go
func (d *DB) Close() error

Close closes the underlying database connection.

#DB.Insert

Go go
func (d *DB) Insert(rec *DeletionRecord) (int64, error)

Insert inserts a DeletionRecord and returns the auto-increment ID.

The origin invariants are checked here, before anything is written: see ErrOriginVersionWithoutName for why they live in code rather than in the schema.

#DB.QueryByID

Go go
func (d *DB) QueryByID(id int64) (*DeletionRecord, error)

QueryByID retrieves a single record by ID. Returns ErrNotFound if it does not exist.

#DB.QueryByUUID

Go go
func (d *DB) QueryByUUID(uuid string) (*DeletionRecord, error)

QueryByUUID retrieves a single record by its archive uuid. Returns ErrNotFound if it does not exist.

The uuid is the identifier a record keeps: the numeric id is this database's autoincrement counter, while the uuid names the archived entry on disk and is what delete hands back to its caller.

#DB.QueryByPath

Go go
func (d *DB) QueryByPath(path string) ([]*DeletionRecord, error)

QueryByPath returns all non-restored records matching the given original_path, ordered by deleted_at DESC (newest first).

#DB.QueryAll

Go go
func (d *DB) QueryAll(includeAll bool) ([]*DeletionRecord, error)

QueryAll returns all records ordered by deleted_at DESC. If includeAll is false, restored and purged records are excluded.

#DB.MarkRestored

Go go
func (d *DB) MarkRestored(id int64, restoredTo string) error

MarkRestored sets restored_at to now and restored_to to the given path. Returns ErrNotFound if the record does not exist.

#DB.MarkPurged

Go go
func (d *DB) MarkPurged(id int64) error

MarkPurged sets purged_at to now, preserving the metadata record. Returns ErrNotFound if the record does not exist.

#DB.QueryOlderThan

Go go
func (d *DB) QueryOlderThan(before time.Time) ([]*DeletionRecord, error)

QueryOlderThan returns all non-restored, non-purged records deleted before the given time.

#internal/git

Package git provides helpers for managing the git index alongside saferm's archive/restore operations.

#IsInGitRepo

Go go
func IsInGitRepo(dir string) bool

IsInGitRepo returns true if dir is inside a git working tree.

#IsGitTracked

Go go
func IsGitTracked(path string) bool

IsGitTracked returns true if the file at path is tracked by git (i.e., known to the index). The path must be absolute or relative to the current working directory; the command runs from the file's parent directory.

#GitRmCached

Go go
func GitRmCached(path string, recursive bool) error

GitRmCached stages the removal of path in the git index without touching the working tree (the file is already archived). When recursive is true, -r is added for directory removal.

#GitAdd

Go go
func GitAdd(path string) error

GitAdd stages a file in the git index. The command runs from the file's parent directory.

#internal/meta

Package meta collects rich metadata about each deletion: environment variables, git repository context, parent process information, and arbitrary user-supplied key-value pairs.

#Metadata

Go go
type Metadata struct

Metadata holds contextual information captured at deletion time.

#Collect

Go go
func Collect(excludePatterns []string, customMeta map[string]string) (*Metadata, error)

Collect gathers metadata from the current environment.

Best-effort per collector -- git context, parent-process details and the process ancestry read from the trace store degrade to empty values, or to a recorded anomaly, rather than failing. The one thing it refuses to work around is an exclude pattern that does not compile: that is the caller asking for a redaction, and continuing without it would write the very variables the caller meant to keep out. See collectEnv.

#internal/trace

Package trace reads the strictcli process trace store, so a deletion can record who ran it.

The store is a shared, append-only JSONL record of process ancestry: at the seam where one command-line tool spawns another, the spawning invocation writes one line describing itself and hands that line's identifier to the child in STRICTCLI_TRACE_PARENT. saferm is a consumer, never a writer. It parses the variable and reads the store itself, per the normative specification (strictcli's docs/process-trace-store.md), because the framework deliberately exposes no accessor for the ancestry stack -- nothing in the framework may branch on data no framework code reads.

Everything here is observational. A capture cannot fail a deletion: an absent variable, a polluted one, a pruned store, a torn line and a parent that resolves to nothing are all legal states, each recorded as an anomaly and carried on from. Consumers noticing dangling parents is the store's primary failure-detection channel, which is why the anomalies are written into the record rather than discarded.

A capture resolves the FULL ancestry chain at capture time and keeps the flattened entries, not only their identifiers, so the record stays self-contained: age-based pruning of the store can never orphan it. The identifiers are kept alongside for correlation with whatever store data still exists.

#ParentEnv

Go go
const ParentEnv = "STRICTCLI_TRACE_PARENT"

ParentEnv is the one variable ancestry travels through. It carries exactly one thing: the identifier of the entry describing the process that spawned this one.

#AnomalyMalformedParentValue

Go go
const AnomalyMalformedParentValue = "malformed-trace-parent"

AnomalyMalformedParentValue: the environment variable was set to something that is not a canonical identifier. Recorded verbatim.

#AnomalyDanglingParent

Go go
const AnomalyDanglingParent = "dangling-parent"

AnomalyDanglingParent: an identifier resolved to no entry -- the store was pruned or missing, the writer was another tool, or someone set the variable by hand. Legal by design, and the store's primary failure-detection channel.

#AnomalyMalformedEntry

Go go
const AnomalyMalformedEntry = "malformed-entry"

AnomalyMalformedEntry: a line in a partition could not be read as an entry -- torn by a non-atomic write, missing one of the thirteen keys, or carrying an unparseable identifier.

#AnomalyStoreUnreadable

Go go
const AnomalyStoreUnreadable = "store-unreadable"

AnomalyStoreUnreadable: a partition or the store directory could not be read at all. A store that does not exist is NOT this: that is an ordinary dangling parent.

#AnomalyChainCycle

Go go
const AnomalyChainCycle = "chain-cycle"

AnomalyChainCycle: walking parent_id revisited an identifier. No store a conforming writer produces can contain one, since an entry's parent is always older than itself.

#AnomalyOversizedField

Go go
const AnomalyOversizedField = "oversized-entry-field"

AnomalyOversizedField: a string an entry contributes to the embedded chain was longer than a chain entry may carry, and was truncated. The line was conforming -- nothing in the entry rules bounds a value's length -- so this is the consumer stating what it kept, not a complaint about the writer.

#AnomalyAnomaliesDropped

Go go
const AnomalyAnomaliesDropped = "anomalies-dropped"

AnomalyAnomaliesDropped: the capture saw more anomalies than one record may carry. Synthetic, always last, and present only when something was dropped: it names how many, so a truncated anomaly list can never read as a complete one.

#Entry

Go go
type Entry struct

Entry is one line of the store: an invocation that spawned a child. Every key is always present in a conforming line, so an absent one makes the line malformed rather than defaulted.

#Anomaly

Go go
type Anomaly struct

Anomaly is something the capture saw and could not treat as well-formed.

#Capture

Go go
type Capture struct

Capture is what one deletion records about its ancestry.

Chain holds the flattened ancestry, nearest caller first, so the record stays readable after the store is pruned. ChainIDs is the same walk as bare identifiers, kept for correlation with whatever store data still exists.

#Collect

Go go
func Collect() *Capture

Collect resolves the ancestry of the running process from the store.

It returns nil when STRICTCLI_TRACE_PARENT is unset -- nothing claimed this invocation, which is not an anomaly and is the state every deletion is in until callers upgrade to a framework that writes the store.

#StoreDir

Go go
func StoreDir(home string) string

StoreDir is the store's literal path under home.

It is deliberately NOT derived from XDG_DATA_HOME or any other variable, despite matching the XDG default: a writer that honoured XDG_DATA_HOME and one that did not would write to two stores on the same machine, and a chain crossing them would dangle at both ends while both writers behaved correctly.

#Capture.Origin

Go go
func (c *Capture) Origin() (name, version *string)

Origin is the immediate caller's declared name and version -- the two values a deletion records as its origin. Both are nil when nothing resolved, which is what "no tool claimed this" means.

More tools from this site

  • claudestream Drive Claude Code from Python: run it as a subprocess and read its output as typed events, with async and sync sessions, sandbox policies, and tools you define in Python
  • claudewheel A TUI Claude Code Launcher that lets you have more than one profile, manage sessions lifecycle, pick the exact CC version, model to use (even older unlisted ones), pick which GitHub account to use, etc.
  • dirstat Fast, single-binary directory statistics CLI: every file under a tree grouped by format, with counts, sizes, and lines of code, as a colored terminal table or as JSON
  • fastware A batteries-included ASGI framework: msgspec JSON, a managed Granian server, dependency injection, SSE, WebSockets, auth, and a test client
  • go-toml-edit Zero-dep TOML editing library for Go with comment preservation
  • howmuchleft The fastest Claude Code statusline: context window, 5-hour, and weekly limit usage as three customizable gradient bars, rendering in about 6 ms
  • orxtra
  • pgdesign
  • predraw Declarative rendering pipeline: describe a scene in JSON and get SVG, PNG and WebP out, with light and dark style tokens, reusable components and text converted to path outlines
  • reposummary Turn a git repository's history into a Markdown journal: pick a time window or revision range and get a readable digest of what changed, optionally narrated by an LLM
  • rlsbl Release orchestration and project scaffolding CLI that bumps versions, validates a structured JSONL changelog, tags only the commit CI verified, and publishes to npm, PyPI, Go and more
  • safegit git wrapper CLI that gives each commit its own temporary index and retries ref updates on conflict, so concurrent agents share one repository
  • selfdoc Static Site Generator that builds a project's documentation site directly from its source code, so the docs can never drift from the code they describe, with SEO/AEO, first-class blog, search, and cross-project linking built in
  • strictcli
  • stricttest An always-on test-isolation floor: a pytest plugin and a Go env-hygiene module that make a test suite structurally unable to reach real credentials, the real HOME, the network, or the development repository.
  • wesktop A Python framework that turns an ASGI web app into a desktop application, serving it from a local Granian server and displaying it in a native OS window via pywebview
Search