safekeep
Selective always-on backups for macOS: watches source folders with fswatch and copies only the files you chose to their destinations — an allow-list model that never deletes anything in the backup (a file removed or renamed at the source stays in the backup).
Features
- Allow-list: a file is copied only if it matches at least one
include:; no match → not copied. Unmatched directories are still traversed, while directories with an excluded verdict are pruned together with their subtree. - Atomic copy, never a half-written file: the temp file lives in the
destination's own directory, followed by
fsync+os.replace→ a reader sees either the old file or the new one, never a partial copy. - Reconcile at startup, every 24h, on remount, and on
sync-once: copies whatever differs by size/mtime, idempotently — lost events, crashes and reboots don't matter, the next reconcile brings everything back in sync. - Unmounted volumes: missing destination →
pendingstate with exponential backoff (1s → 60s cap), then a reconcile once the mount is back. - launchd at boot: an agent with
RunAtLoad+KeepAlivekeeps the process alive and restarts it if it dies. .synccarries rules only, destinations live only in~/.safekeep: a.syncfile can neither add nor remove destinations (adest:line is an invalid line), so a repo cloned from a third party can't redirect the backup somewhere else.
Installation
pipx install safekeep # recommended for a CLI
# or
pip install safekeep
Requirements: macOS, Python >= 3.9 and fswatch:
brew install fswatch
The launchd agent (daemon at boot) is installed from a checkout of this
repository with ./install.sh — not from the wheel — see
Agent (launchd) below.
Agent (launchd)
git clone https://gitlab.com/foxhound87/safekeep.git
cd safekeep
# only external dependency, if not installed yet
brew install fswatch
# copies the example into ~/.safekeep, renders the plist, runs preflight checks
bash install.sh
Then:
- Fill
~/.safekeepwith your realdest:entries — and, optionally,source:(the example ships with placeholders — seeexamples/safekeep.example); - drop a
.syncfile in the root of every project you want to follow (seeexamples/sync.example); a directory without a.syncis not tracked; - load the agent:
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.safekeep.agent.plist
# unload with: launchctl bootout gui/$(id -u)/com.safekeep.agent
Once you have granted TCC/FDA (Full Disk Access) permissions to the Python
interpreter and to fswatch, run safekeep doctor for diagnostics.
Quickstart
~/.safekeep (the only place where destinations live):
# observed roots: only directories containing a .sync are followed
source: ~/Code
source: ~/Projects
# destination for ALL projects
dest: ~/Backup/safekeep
# relative (default): <dest>/<path relative to the source root>
# ~/Code/myapp/docs/a.md → ~/Backup/safekeep/myapp/docs/a.md
layout: relative
log_level: info
source: is optional. With it present (source mode) the watched roots are
exactly those entries, as always. Without it safekeep switches to
auto-discovery: it scans $HOME for .sync files (skipping hidden
directories, Library, .Trash, .cache, node_modules, .git, .venv,
__pycache__, venv), watches $HOME, and picks up any .sync created after
startup. dest: stays mandatory in both modes.
~/Code/myapp/.sync (rules only, no destinations):
name: myapp
# allow-list semantics: without these lines NOT A SINGLE file would be copied
*.md
.env
!secrets/old.env # ! = exclude
Rules can be written as bare gitignore-style lines — the inverse of gitignore:
a line lists what to copy, not what to ignore (*.md includes markdown here,
excludes it in a .gitignore), and a leading ! turns it into an exclude:.
Bare lines share the same ordered list as the explicit include:/exclude:
keys, so last-match-wins works the same way (see
examples/sync.example).
Dry run first, then start the daemon:
safekeep sync-once --dry-run # prints what would be copied, copies nothing
safekeep run # daemon: watch + copy (foreground)
CLI commands
safekeep <command> [--config PATH] [-v]
| Command | What it does |
|---|---|
run |
daemon: initial reconcile, fswatch loop, event dispatch, 24h timer |
sync-once [--dry-run] [--project PATH] [--prune] |
a single pass: walks the source and copies whatever differs (--dry-run only prints what it would copy; --prune also removes dest files whose source still exists but is no longer included) |
status |
read-only: config, sources, discovered projects with N rules, destination states |
doctor |
diagnostics: config, fswatch, TCC, launchd plist — exits non-zero if a fatal check fails |
Tests
python3 -m unittest discover -s tests
Stdlib (unittest) suite, zero dependencies: 156 tests covering the matcher,
config, atomic copy, volumes, daemon, auto-discovery and CLI. fswatch is not
needed to run the tests.
Documentation
Everything in detail (config format, pattern semantics, event → sync flow,
atomic copy, launchd, edge cases) lives in SPEC.md; commented
examples in examples/.
License
MIT — see LICENSE
Metadata
Release files for safekeep 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| safekeep-0.2.0.tar.gz | 48.7 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| safekeep-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 79.5 kB
Release files / safekeep-0.2.0.tar.gz
| Download URL | safekeep-0.2.0.tar.gz |
|---|---|
| Size | 48.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
24baad81cdadf57d8e0793957b1c3914cc4efffdb68f9a0d5593b70bd1092c33
|
|
BLAKE2b-256 checksum How to use checksums |
709c714cf8c310698a3a87c133eee86bf9cf729d57b93d92d8d1bf619ab162d0
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.12.15
|
Release files / safekeep-0.2.0-py3-none-any.whl
| Download URL | safekeep-0.2.0-py3-none-any.whl |
|---|---|
| Size | 30.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
94c2cf6a78b19cfdc6065b8db516abd113632abed2cfc0bf977d20b8ad950333
|
|
BLAKE2b-256 checksum How to use checksums |
274e9c03f26bb9c71c0207feeb33c3f881e639f5d931a7a32564e6695e73336c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.12.15
|