
A terminal UI for rsync ๐

[](https://ratatui.rs)
[](https://crates.io/crates/lazyrsync)
[](LICENSE)
[](https://lazyrsync.westpoint.io)
A terminal UI for `rsync` โ manage reusable profiles, preview a transfer as a
structured diff **before** running it, and watch a live run with progress and
cancellation. All from the terminal, including over SSH where a desktop GUI
can't reach.
## Contents
- [Why](#why)
- [Features](#features)
- [Install](#install)
- [Quickstart](#quickstart)
- [Headless & scheduling](#headless--scheduling)
- [Keybindings](#keybindings)
- [Configuration](#configuration)
- [Contributing](#contributing)
- [Acknowledgements](#acknowledgements)
- [License](#license)
## Why
`rsync` is the right tool for backups and syncs, but its flags are easy to get
wrong and a single mistake can delete data. lazyrsync keeps you in the terminal
while giving you the safety of a GUI: save your transfers once, see exactly what
a run will change before it runs, and keep destructive flags behind a gate.
## Features
### Profiles & tasks
Save a Source โ Destination pair once and rerun it with a keystroke.

### Dry-run preview
Press `p` and watch the transfer resolve into a `+`/`~`/`-` diff with stats.
Nothing is written until you say so.

### Live run & cancel
`r` runs it โ a progress bar fills with byte and file counts. Press `c` to
stop mid-transfer.

### Flags & `--delete` gating
Toggle rsync's options as checkboxes. Flip on `--delete` and it makes you
confirm before anything can be removed.

### Over SSH
Put a `user@host:/path` on either side of a task and it runs over SSH โ remote
source downloads, remote destination uploads.

### Snapshots
Keep numbered, hardlinked versions with `--link-dest` โ each run writes the
next directory (`1/`, `2/`, โฆ).

## Install
**rsync 3.1 or later** must be on your `$PATH` โ the preview needs
`--itemize-changes`, the progress bar needs `--info=progress2` (added in
3.1.0) and snapshots need `--link-dest`. The Homebrew and AUR packages pull
rsync in; `cargo install` and `cargo binstall` do not.
On macOS 15.4 and later `/usr/bin/rsync` is openrsync, which has no
`--itemize-changes` and no `--info` at all, and accepts `--link-dest` without
reliably hardlinking. `brew install rsync`, then either put it ahead of
`/usr/bin` on `$PATH` or set `rsync_path` (see
[Choosing which rsync runs](#choosing-which-rsync-runs)). lazyrsync warns at
startup when the rsync it finds is older than 3.1.
```bash
cargo install lazyrsync # crates.io
cargo binstall lazyrsync # prebuilt release binary
brew install lazyrsync # Homebrew
yay -S lazyrsync # AUR (Arch)
```
Or build from source:
```bash
cargo install --path .
```
## Quickstart
```bash
lazyrsync # launch the TUI
```
1. Press `]` to switch to the **Profiles** sub-tab, then `a` to add a profile.
2. Back on **Tasks** (`]`), press `a` to add a task: an **ID**, an **Action**
(Sync โ Snapshot with `โ/โ`), a **Source**, and a **Destination**. Either
path may be local or a remote `user@host:/path`.
3. Press `p` to **preview** (dry-run) โ you'll see the exact `+`/`~`/`-`
changes and stats, and nothing is written.
4. Press `r` to **run** it. Watch progress in the **Runs** panel; press `c` to
cancel.
A task is just **Source โ Destination**, exactly like the rsync command line โ
no push/pull, no separate "remote" field. A trailing `/` on the Source copies
its _contents_; without it, the folder itself is copied.
## Headless & scheduling
Every profile you build in the TUI also runs without it, so the transfer you
verified by hand is the exact one your scheduler runs at 2am.
```bash
lazyrsync list # profiles, task ids, resolved rsync commands
lazyrsync run backups # every task in the profile
lazyrsync run backups/photos-3f2a # a single task, by id from `list`
lazyrsync run backups -n # real dry run, changes nothing
lazyrsync run backups --yes # required if any task uses --delete
lazyrsync run backups -v # add rsync's own output for each task
```
Flags compose freely โ `lazyrsync run backups/photos-3f2a -n --yes` is valid.
(`list` prints the command with the TUI's `--info=progress2` flag; a headless
run drops it, since there's no progress bar to feed. A headless `-n` also drops
`--stats`, which only the TUI's preview parser reads โ you get rsync's itemized
diff without the fourteen-line statistics block after every task.)
A real run passes rsync `-q`, so you get **one line per task** and nothing
else โ rsync still prints its errors, which is the only chatter worth mailing.
`-v`/`--verbose` opts back into the full log per task. A dry run is **never**
quiet: the itemized diff is the whole point of `-n`, so `-n` shows it with or
without `-v`.
Why not just point cron at `rsync` directly? For a plain Sync you could. A
**Snapshot** you can't: the numbered destination directory and the
`--link-dest` chain are computed at run time by scanning the destination, so
the command differs on every run โ `1/` links against nothing, `2/` links
against `1/`, and so on. No static crontab line can express that.
`lazyrsync run` resolves it fresh each time.
### Ordering
Tasks run in the order `lazyrsync list` shows them, which is **not** the order
they appear in `profiles.toml` โ the loader sorts by recency. Check `list`
before you schedule anything that assumes an order.
A failing task doesn't stop the rest. Every task gets its turn, then you get a
summary and a non-zero exit code โ so one broken source leaves less data
unprotected than aborting the batch would.
### Exit codes
| Exit | Meaning |
|------|---------|
| 0 | every task succeeded โ or the profile has no tasks, which says so on stderr |
| 1 | refused: a task uses `--delete` and `--yes` was absent; nothing ran |
| 2 | no such profile or task id, or the config is missing or failed to load |
| 3 | a task couldn't be started, or was killed by a signal |
| _n_ | the first failing task's own rsync exit code |
rsync's exit 24 โ source files vanished mid-transfer โ counts as success.
rsync's own exit codes 1, 2 and 3 overlap these, so the status alone isn't
always conclusive; read the message. A refusal always says `nothing ran`
explicitly, and a task that never started ends its line with `exit 3`.
### Output streams
Successful task lines and the success summary go to **stdout**. Failed task
lines and the summary-when-something-failed go to **stderr**. So:
```bash
lazyrsync run backups >/dev/null
```
is completely silent when every task succeeds, and produces output only when
something needs your attention โ which is what makes cron's mail-on-output
behaviour useful instead of noisy.
Each task gets one styled line: a counter, `โ`/`โ`, the task's label, and then
elapsed time if it worked or the exit code and the **task id** if it didn't โ
the id being what you paste back into `lazyrsync run backups/