btrfs-timeline
Browse and restore previous versions of your files from btrfs snapshots — the way Time Machine does it, from a browser.
Status: early development. Browsing history, previewing versions and restoring files all work — from the CLI, from a standalone web UI, and as a Cockpit module. See Roadmap.
One file, four rows: a stretch when it did not exist yet, two contents it has held since, and the file as it stands now. Forty-eight snapshots went into that — the ones that changed nothing are not worth a row.
Why this exists
If you run btrfs with automatic snapshots (snapper, btrbk, Timeshift), you already have every previous version of every file. Getting one back, however, means knowing where the snapshots live, guessing which one is old enough, and comparing files by hand on the command line. It works, but it is expert-only — and it is the one thing people actually need snapshots for.
The tools that exist today each stop somewhere short of this:
| Tool | What it does | Why it may not fit |
|---|---|---|
| httm | Excellent interactive file history for ZFS/btrfs | CLI/TUI only |
| Btrfs Assistant, Snapper GUI | Snapshot management | Desktop app; not per-file history |
Samba vfs_shadow_copy2 |
"Previous Versions" in Windows Explorer | Requires a Windows client |
| Rockstor, OpenMediaVault | Full NAS web UI with btrfs support | NAS-only distributions; share-level rollback, not per-file |
Cockpit storaged |
btrfs filesystem/subvolume creation | No snapshot browsing; multi-device btrfs unsupported |
Nothing lets you open a browser, point at a path, and walk back through time. That is what this is.
Design
The project is meant to ship in three forms, and they share one thing:
btrfs_timeline/core/ Library: snapshot discovery, version history, restore
btrfs_timeline/cli.py CLI with --json on every subcommand <- the shared contract
btrfs_timeline/web/static/ The screen: index.html + app.js + style.css
transport.js <- the only file a front-end replaces
|- standalone web imports core directly (single process)
|- Cockpit module cockpit.spawn([... , "--json"], {superuser: "require"})
`- desktop GUI imports core, or calls the CLI
A Cockpit module has no server side — it is static files plus a manifest.json, and
it reaches the system by spawning processes from the browser. So the only shape a shared
core can take is a CLI that emits JSON. Every subcommand here has --json for that
reason; treat its output as a public API.
The screen is built in the browser, not on the server. A Cockpit module has no server
to render with, so anything baked into the HTML could not be reused there. Instead
app.js imports its data from transport.js, and that one file is what each front-end
swaps: fetch('./api/history?…') here, cockpit.spawn there. The rest of the UI — and
the translations, which come from the same JSON catalogs the CLI uses — is shared.
Two decisions follow from measurements rather than taste:
- No
btrfs subvolume list. It requires root (Operation not permittedotherwise), while the files inside snapshots are readable under ordinary permissions. Discovery is done with/proc/self/mountinfoplus directory traversal, so browsing history does not need root. - Deduplication is mandatory. btrfs is copy-on-write, so an unchanged file appears once per snapshot. On the author's machine, 35 snapshots collapse to a single version. Versions are merged by mtime and size, and the periods when a file did not exist are preserved as their own entries — otherwise a deleted-then-recreated file looks continuous.
Installation
pipx install btrfs-timeline # CLI only, no dependencies
pipx install 'btrfs-timeline[web]' # with the standalone web UI
Requires Python 3.9+ and Linux. btrfs-progs is not required for browsing history.
Usage
# What versions of this file exist?
btrfs-timeline history ~/notes.md
# Machine-readable, for scripts and for the Cockpit module
btrfs-timeline history ~/notes.md --json
# Which snapshots cover this path, and what layout are they in?
btrfs-timeline snapshots ~/
# Which btrfs mounts does this system have?
btrfs-timeline mounts
# What is in this directory? (the web UI uses the same call)
btrfs-timeline browse ~/Documents --json
# What was in it back then — including what has been deleted since
btrfs-timeline browse ~/Documents --snapshot 1729
# What changed between a version and the file as it is now
btrfs-timeline diff ~/notes.md --from 2
btrfs-timeline diff ~/notes.md --from 2 --to 3
# Show a past version, or what changed
btrfs-timeline preview ~/notes.md --index 2
btrfs-timeline diff ~/notes.md --from 2
# Open the web UI
btrfs-timeline serve
Example output:
/home/akiva/.gitconfig
# FIRST SEEN LAST SEEN SIZE SNAPS STATE
1 2024-10-02 02:00:08 2025-01-01 00:00:00 - 3 (does not exist)
2 2025-12-01 00:00:08 2026-03-01 00:00:00 268 B 4 ok
3 2026-04-01 00:00:00 2026-09-23 01:00:00 297 B 28 ok
4 - - 297 B - live
Thirty-five snapshots, three meaningful versions, and the gap before the file was created — which is the point.
Restoring
# Bring back the newest version found in snapshots, next to the original file
btrfs-timeline restore ~/notes.md
# Pick a version by the number shown in the "#" column of `history`
btrfs-timeline restore ~/notes.md --index 2
# Or by snapshot id
btrfs-timeline restore ~/notes.md --snapshot 10129
# Show what would happen and change nothing
btrfs-timeline restore ~/notes.md --dry-run
# Write somewhere else, or overwrite the original (a backup is kept)
btrfs-timeline restore ~/notes.md --to /tmp/notes.old.md
btrfs-timeline restore ~/notes.md --in-place
Three properties matter here:
- Nothing is overwritten by default. The restored file is written next to the
original as
notes.20260401T000008.md. The worst failure mode for a history tool is destroying the current content of a file while trying to get an old one back, so it is excluded by default rather than guarded by a prompt.--in-placeoverwrites, and even then the current content is kept asnotes.before-restore.<timestamp>.mdunless you pass both--no-backupand--force. - It is a reflink, not a copy. Restoring uses the
FICLONEioctl, so it shares extents with the snapshot: instant regardless of file size, and no extra space used. If the destination is on another filesystem it falls back to a plain copy, and the output says which one happened. - Writes are atomic. Content goes to a temporary file in the destination directory
and is moved into place with
rename(2), so an interrupted restore never leaves a half-written file.
Web UI
pipx install 'btrfs-timeline[web]'
btrfs-timeline serve # http://127.0.0.1:8088/
btrfs-timeline serve --root ~/Documents # only allow reading below this directory
btrfs-timeline serve --read-only # history only, no restoring
Walk the filesystem on the left, click a file, and its versions appear on the right. Each version can be previewed before you decide, and restored with one button. Restoring always shows a dry run of exactly what it will write — and where the current content will be kept — before asking you to confirm.
There is one time axis, not a set of modes. Everything the screen does is a combination of two things: a path, and a point in time.
- Click a directory and you get its history too. Pick one of its versions and the listing on the left becomes what that directory held at that moment.
- Files that were deleted since appear there, struck through. They are invisible in the current filesystem, so this is the only way to reach them — and from there their history and restore work exactly as they do for any other file.
- The preview pane doubles as a diff: compare a version against the current file, or against any other version, with the selector next to the toggle.
No tabs were added for any of this. Adding one screen per feature would mean learning the same "look at the past" gesture three times over.
Two settings sit in the page and are remembered per browser: the language, picked from the same catalogs the CLI uses, and whether to show hidden files — off by default, because a home directory is mostly dotfiles and the things you came for get buried in them.
It listens on loopback only by default, and it deliberately refuses to listen on any
other address without --auth USER:PASSWORD (also read from BTRFS_TIMELINE_AUTH),
because anyone who can reach it can read your files and write over them. --allow-no-auth
overrides that if you really mean it. Restoring is a POST and is rejected when the
request carries a foreign Origin: basic auth alone would not stop another site from
making your browser send the request for you.
The server runs as you, so it can only read what you can read. It does not need root —
that is the point of not using btrfs subvolume list.
Cockpit module
If you already run Cockpit, the same screen is available inside it:
btrfs-timeline cockpit install # into ~/.local/share/cockpit
btrfs-timeline cockpit install --system # into /usr/share/cockpit, for every user
btrfs-timeline cockpit uninstall
Reload Cockpit and look under Tools for File history. No restart, no service. The menu entry is translated from the same catalogs as everything else, so it follows the language Cockpit is being used in.
It is the same index.html, app.js, i18n.js and style.css — copied, not rewritten.
The only file that differs is transport.js, which calls the CLI instead of an HTTP API:
cockpit.spawn([...COMMAND, 'history', path, '--json'])
It asks for no privilege escalation. Browsing snapshots does not need root, and
restoring writes as the logged-in user, so there is no reason to demand more. The tests
run the real app.js through both transports and assert the two end up with the same
screen.
Because cockpit.spawn does not inherit your shell's PATH, install records the
absolute path of the installation you ran it from. Run it again after you change how
btrfs-timeline itself is installed.
Devices
btrfs-timeline devices
btrfs-timeline devices --json
What each filesystem is made of, which profile every kind of allocation uses, whether a device is missing, whether something is running, and the per-device error counters — the numbers that tell you a disk, a cable or a power supply is on its way out.
This does not need root. It deliberately avoids btrfs filesystem show, which does:
that command opens the raw block devices and fails with Permission denied for anyone
else. Everything here comes from /sys/fs/btrfs/ and btrfs device stats --format json,
both readable as an ordinary user — the same reason browsing history needs no privileges.
Every command run on your behalf is printed with its result and how risky it was:
ran: btrfs --format json device stats / [safe]
That disclosure is not decoration. Operations declare their risk — safe changes nothing, caution changes state but can be interrupted or undone, dangerous can lose data — and anything dangerous cannot run at all until it has been confirmed. The write operations land on this: scrub and balance next, then device add, remove and replace.
Maintenance
btrfs-timeline scrub status /home
btrfs-timeline scrub start /home --sudo
btrfs-timeline balance start /home --usage 20 --sudo
Scrub reads every block, checks it against its checksum, and repairs what it can from
another copy. Balance rewrites chunks to even allocation out across devices; --usage 20
only touches chunks less than 20% full, which is the usual answer to fragmented free
space and is far lighter than a full balance.
Nothing runs without showing you what it is and asking first:
$ btrfs-timeline scrub start /home
about to run: btrfs scrub start /home
risk: changes state - read every block of /home and repair what has a good copy elsewhere
run it? [y/N]
Operations declare a risk — safe changes nothing, caution changes state but can be
interrupted or resumed, dangerous can lose data — and only safe ones skip the
question. With no terminal to ask at, it refuses rather than assuming yes; pass --yes
when you mean it.
These need root, and it will not quietly escalate. It tells you the exact command to
run, including the sudo prefix, and only adds sudo itself when you pass --sudo. What
it prints and what it runs are the same string, so the confirmation cannot be about a
different command than the one that executes.
scrub status is the one thing here that works unprivileged, because it reads the record
kept under /var/lib/btrfs/. balance status needs root like the rest.
Translations
Messages are translated at runtime from JSON catalogs in
btrfs_timeline/locales/. en.json is the reference, and any
key a catalog is missing falls back to English — so a partial translation is useful from
its very first line.
btrfs-timeline history ~/notes.md --lang ja # this invocation only
BTRFS_TIMELINE_LANG=ja btrfs-timeline ... # this shell
With neither, the language comes from LC_ALL, LC_MESSAGES or LANG, and falls back
to English; LANG=C means English. Only human-readable output is translated. --json is
byte-identical in every language, because front-ends and scripts consume it.
Adding a language
Copy en.json to <code>.json — an ISO 639-1 code such as de, or a regional variant
such as pt_br, which falls back to pt if that catalog exists — and translate the
values, starting with language.name, which is how your language names itself (日本語,
not Japanese): that string is what the web UI's language picker shows, so someone who
reads only your language has to be able to find it. No code changes are needed. The
new language is picked up automatically, both in the values --lang accepts and in the
picker.
The test suite (pytest tests/test_i18n.py) enforces two rules:
- Every key from
en.jsonis present, and nothing extra is. - Placeholder names match (
{path},{index}). Their order within a sentence is yours to change — that is exactly why they are named rather than positional.
Tables are aligned by terminal display width rather than character count, so East Asian full-width characters line up correctly.
Why JSON and not gettext: .po files need a compilation step to .mo, and the web UI
and Cockpit module render in the browser, where .mo is unusable. A single catalog
format that both Python and JavaScript can read keeps the translations from being
maintained twice.
Supported snapshot layouts
| Layout | Path shape | Typical source |
|---|---|---|
| snapper | <mount>/.snapshots/<N>/snapshot + info.xml |
snapper |
| flat | <mount>/.snapshots/<name> |
btrbk, Timeshift, manual |
Snapshot timestamps come from snapper's info.xml (which records UTC, despite
carrying no timezone marker), from a timestamp embedded in the directory name, or from
the directory mtime, in that order.
Roadmap
- File history browser, standalone web UI — done
- Cockpit module — done: same CLI, same screen, only
transport.jsdiffers - Dashboard and device management —
devices,scrubandbalanceare done; next is device add/remove/replace, and a dashboard screen in the browser - Desktop GUI
Device management (3) can destroy a filesystem when it goes wrong, which is a different class of risk from reading history. It will stay a separate module with separate privileges.
Notes and limitations
- Symlinks are resolved against the live filesystem, so history follows the target of a link (useful for dotfiles). Links inside snapshots are never followed, so a snapshot never reports live content as if it were old.
- Versions are compared by mtime and size, not by content hash. Hashing would mean reading every version of every file.
- Restoring a whole directory is not supported yet — only individual files. You can see what a directory held at any point and restore the files out of it one by one, but there is no single "put this directory back". Restoring part of a tree silently would be worse than refusing.
- A directory's history comes from the directory's own mtime, which btrfs preserves. That changes when entries are added, removed or renamed — not when a file inside is edited. So directory versions mark what came and went, which is the granularity you want for finding something deleted; use the file's own history for edits.
- A restored file keeps the original's permissions and mtime, but not its owner:
chownrequires root, and this tool is meant to run unprivileged. devicesdoes not show how much of each device btrfs has allocated. That needs the chunk information, which is the one thing here that does require root, and it is not worth asking for it to fill in a column.- btrfs RAID 5/6 is still not considered production-ready upstream; this tool does not change that.
Alternatives
If you want a terminal tool rather than a browser, use httm — it is mature, fast, and covers ZFS too. If you want a full NAS appliance, use Rockstor or OpenMediaVault.
License
MIT — see LICENSE.
Release files for btrfs-timeline 0.4.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 | |
|---|---|---|---|
| btrfs_timeline-0.4.0.tar.gz | 117.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| btrfs_timeline-0.4.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 208.0 kB
Release files / btrfs_timeline-0.4.0.tar.gz
| Download URL | btrfs_timeline-0.4.0.tar.gz |
|---|---|
| Size | 117.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
aa2fdc0617614125c3135b96136c21906911fa76a6ee0e262422f6d448fb246c
|
|
BLAKE2b-256 checksum How to use checksums |
55a277b0556c04de0ce342f5197e585d85242a3d89779a6fd7ad031dbb7a6ca2
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 23, 2026.
Transparency logRelease files / btrfs_timeline-0.4.0-py3-none-any.whl
| Download URL | btrfs_timeline-0.4.0-py3-none-any.whl |
|---|---|
| Size | 90.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
1bd734851784a5a5f354f8840bb59b79b2f28748090e77f3b6f7091843d12c3e
|
|
BLAKE2b-256 checksum How to use checksums |
8d0d3dd22fb381d1936b54ac67583d568e952f705dfdcbdbdae03d0281ca28c3
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 23, 2026.
Transparency log