Metadata-Version: 2.4
Name: safekeep
Version: 0.2.0
Summary: Selective always-on backups for macOS: fswatch-driven copy of allow-listed files, never deletes
License-Expression: MIT
Project-URL: Homepage, https://gitlab.com/foxhound87/safekeep
Project-URL: Repository, https://gitlab.com/foxhound87/safekeep
Keywords: backup,macos,fswatch,sync,allow-list,launchd
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: System Administrators
Classifier: Operating System :: MacOS
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: System :: Archiving :: Backup
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Dynamic: license-file

# safekeep

**Selective always-on backups for macOS**: watches source folders with
[fswatch](https://github.com/emcrisostomo/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 → `pending` state with exponential
  backoff (1s → 60s cap), then a reconcile once the mount is back.
- **launchd at boot**: an agent with `RunAtLoad` + `KeepAlive` keeps the process
  alive and restarts it if it dies.
- **`.sync` carries rules only, destinations live only in `~/.safekeep`**: a
  `.sync` file can neither add nor remove destinations (a `dest:` line is an
  invalid line), so a repo cloned from a third party can't redirect the backup
  somewhere else.

## Installation

```bash
pipx install safekeep     # recommended for a CLI
# or
pip install safekeep
```

**Requirements**: macOS, Python >= 3.9 and
[fswatch](https://github.com/emcrisostomo/fswatch):

```bash
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)](#agent-launchd) below.

## Agent (launchd)

```bash
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:

1. Fill `~/.safekeep` with your real `dest:` entries — and, optionally, `source:`
   (the example ships with placeholders — see
   [`examples/safekeep.example`](examples/safekeep.example));
2. drop a `.sync` file in the root of every project you want to follow (see
   [`examples/sync.example`](examples/sync.example)); a directory **without** a
   `.sync` is not tracked;
3. load the agent:

```bash
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):

```bash
# 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):

```bash
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`](examples/sync.example)).

Dry run first, then start the daemon:

```bash
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

```bash
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`](SPEC.md); commented
examples in [`examples/`](examples/).

## License

MIT — see [LICENSE](LICENSE)
