19 Commits

Author SHA1 Message Date
3e1b2e5488 Merge pull request 'Add restore feature' (#15) from add_restore_feature into main
Reviewed-on: #15
2025-12-15 17:25:29 +00:00
9adbb74602 Update README, version bump 0.6.0
All checks were successful
Lint & Security / precommit-and-security (pull_request) Successful in 48s
2025-12-15 17:23:08 +00:00
537397ac36 Update tests with --restore flag 2025-12-15 17:19:17 +00:00
0dc9b827f9 Add restore (any) feature 2025-12-15 17:18:40 +00:00
bf0dfac0c4 Fix repository's URL to point ad Gitea 2025-12-14 16:41:37 +00:00
74db589391 Fix installation instructions 2025-12-09 16:14:17 +00:00
cc764403e3 Merge pull request 'Update README and pyproject.toml' (#14) from update_mirro_20251209 into main
Reviewed-on: #14
2025-12-09 15:21:42 +00:00
e66b5d95e9 Edit badges, update installation instructions, swap github.com entries to git.sysmd.uk
All checks were successful
Lint & Security / precommit-and-security (pull_request) Successful in 47s
2025-12-09 15:20:06 +00:00
b23ca5573e Merge pull request 'Rename .github folder to .gitea. Use pre-commit directly instead of action' (#13) from rename_github_folder into main
Reviewed-on: #13
2025-12-09 13:18:47 +00:00
b930b4239e Rename .github folder to .gitea. Use pre-commit directly instead of action
All checks were successful
Lint & Security / precommit-and-security (pull_request) Successful in 49s
2025-12-09 13:12:36 +00:00
Marco D'Aleo
4f681851ac Merge pull request #12 from guardutils/update_mirro_20251204
Add --status flag, TAB completion with argcomplete, update README, update tests
2025-12-04 15:12:37 +00:00
e48adddd4c Add --status flag, TAB completion with argcomplete, update README, update tests 2025-12-04 15:10:03 +00:00
d2fff45db7 Update badges URLs 2025-11-29 16:41:47 +00:00
Marco D'Aleo
b2af78e643 Merge pull request #11 from guardutils/update_mirro_20251127
Switch ownership from mdaleo404 to guardutils in README and pyproject
2025-11-27 17:52:53 +00:00
ea7c1384a0 Switch ownership from mdaleo404 to guardutils in README and pyproject 2025-11-27 17:51:27 +00:00
Marco D'Aleo
e88a248463 Merge pull request #10 from mdaleo404/add_badges_to_readme
Add badges to README
2025-11-23 07:35:19 +00:00
755b69ce99 Add badges to README 2025-11-23 07:34:15 +00:00
Marco D'Aleo
9fa7fc1c52 Update README.md
Fix package name in README
2025-11-21 18:07:54 +00:00
Marco D'Aleo
d982509acb Merge pull request #9 from mdaleo404/update_mirro_20251121
Update mirro 20251121
2025-11-21 17:19:15 +00:00
7 changed files with 333 additions and 163 deletions

View File

@@ -20,7 +20,7 @@ jobs:
run: pip install pre-commit run: pip install pre-commit
- name: Run pre-commit hooks - name: Run pre-commit hooks
uses: pre-commit/action@v3.0.1 run: pre-commit run --all-files --color always
- name: Install pip-audit - name: Install pip-audit
run: pip install pip-audit run: pip install pip-audit

View File

@@ -1,3 +1,7 @@
[![Licence](https://img.shields.io/badge/GPL--3.0-orange?label=Licence)](https://git.sysmd.uk/guardutils/mirro/src/branch/main/LICENCE)
[![Gitea Release](https://img.shields.io/gitea/v/release/guardutils/mirro?gitea_url=https%3A%2F%2Fgit.sysmd.uk%2F&style=flat&color=orange&logo=gitea)](https://git.sysmd.uk/guardutils/mirro/releases)
[![pre-commit](https://img.shields.io/badge/pre--commit-enabled-blue?logo=pre-commit&style=flat)](https://git.sysmd.uk/guardutils/mirro/src/branch/main/.pre-commit-config.yaml)
# mirro # mirro
**mirro** is a tiny safety-first editing wrapper for text files. **mirro** is a tiny safety-first editing wrapper for text files.
@@ -82,12 +86,14 @@ filename.ext.orig.20251110T174400
## Functionalities ## Functionalities
### List all backup files stored in your backup directory. ### List all backup files stored in your backup directory.
``` ```
mirro --list mirro --list
``` ```
Output includes permissions, owner/group, timestamps, and backup filenames. Output includes permissions, owner/group, timestamps, and backup filenames.
### Restore the most recent backup for a given file. ### Restore the most recent backup for a given file.
``` ```
mirro --restore-last ~/.config/myapp/config.ini mirro --restore-last ~/.config/myapp/config.ini
``` ```
@@ -98,25 +104,36 @@ This:
3. and overwrites the target file with its original contents. 3. and overwrites the target file with its original contents.
### Restore ANY backup
```
mirro --restore filename.ext.orig.20251110T174400
Restored /path/to/filename.ext from backup filename.ext.orig.20251110T174400
```
### Remove old backup files. ### Remove old backup files.
``` ```
mirro --prune-backups mirro --prune-backups
``` ```
This removes backups older than the number of days set in `MIRRO_BACKUPS_LIFE`. This removes backups older than the number of days set in `MIRRO_BACKUPS_LIFE`.
### Remove backups older than _N_ days ### Remove backups older than _N_ days
``` ```
mirro --prune-backups=14 mirro --prune-backups=14
``` ```
This keeps the last 14 days of backups and removes everything older. This keeps the last 14 days of backups and removes everything older.
### Remove all backups ### Remove all backups
``` ```
mirro --prune-backups=all mirro --prune-backups=all
``` ```
This deletes every backup in the backup directory. This deletes every backup in the backup directory.
### Environment Variable ### Environment Variable
`MIRRO_BACKUPS_LIFE` controls the default number of days to keep when using `mirro --prune-backups`. `MIRRO_BACKUPS_LIFE` controls the default number of days to keep when using `mirro --prune-backups`.
Its default value is **30** if not set otherwise. Its default value is **30** if not set otherwise.
``` ```
@@ -129,28 +146,80 @@ Invalid or non-numeric values fall back to 30 days.
**Note:** _a value of 0 is **invalid**_. **Note:** _a value of 0 is **invalid**_.
### Built-in diff ### Built-in diff
This shows a _git-like_ diff of the current file version and any of that file backups. This shows a _git-like_ diff of the current file version and any of that file backups.
``` ```
mirro --diff file file.orig.20251121T163121 mirro --diff file file.orig.20251121T163121
``` ```
### Shows current directory's history
Shows which files in the current directory have _**edit history**_ recorded by mirro.
For each file, it prints how many revisions exist and when the latest one was saved.
```
mirro --status
Files with history in /foo/bar:
baz.conf (3 revisions, latest: 2025-01-12 14:03 UTC)
```
## Installation ## Installation
### From package manager ### From GuardUtils package repo
This is the preferred method of installation. This is the preferred method of installation.
**Ubuntu 22.04 and 24.04** ### Debian/Ubuntu
#### 1) Import the GPG key
```bash
sudo mkdir -p /usr/share/keyrings
curl -fsSL https://repo.sysmd.uk/guardutils/guardutils.gpg | sudo gpg --dearmor -o /usr/share/keyrings/guardutils.gpg
```
The GPG fingerprint is `0032C71FA6A11EF9567D4434C5C06BD4603C28B1`.
#### 2) Add the APT source
```bash
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/guardutils.gpg] https://repo.sysmd.uk/guardutils/debian stable main" | sudo tee /etc/apt/sources.list.d/guardutils.list
```
#### 3) Update and install
``` ```
sudo add-apt-repository ppa:mdaleo/mirro
sudo apt update sudo apt update
sudo apt install mirro sudo apt install mirro
``` ```
**Fedora 41, 42, 43** ### Fedora/RHEL
#### 1) Import the GPG key
``` ```
sudo dnf copr enable mdaleo/mirro sudo rpm --import https://repo.sysmd.uk/guardutils/guardutils.gpg
sudo dnf install resrm ```
#### 2) Add the repository configuration
```
sudo tee /etc/yum.repos.d/guardutils.repo > /dev/null << 'EOF'
[guardutils]
name=GuardUtils Repository
baseurl=https://repo.sysmd.uk/guardutils/rpm/$basearch
enabled=1
gpgcheck=1
repo_gpgcheck=1
gpgkey=https://repo.sysmd.uk/guardutils/guardutils.gpg
EOF
```
#### 4) Update and install
```
sudo dnf upgrade --refresh
sudo dnf install mirro
``` ```
### From PyPI ### From PyPI
@@ -172,11 +241,23 @@ pip install mirro
### From this repository ### From this repository
``` ```
git clone https://github.com/mdaleo404/mirro.git git clone https://git.sysmd.uk/guardutils/mirro.git
cd mirro/ cd mirro/
poetry install poetry install
``` ```
## TAB completion
Add this to your `.bashrc`
```
eval "$(register-python-argcomplete mirro)"
```
And then
```
source ~/.bashrc
```
## How to run the tests ## How to run the tests
- Clone this repository - Clone this repository

16
poetry.lock generated
View File

@@ -1,5 +1,19 @@
# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. # This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand.
[[package]]
name = "argcomplete"
version = "3.6.3"
description = "Bash tab completion for argparse"
optional = false
python-versions = ">=3.8"
files = [
{file = "argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce"},
{file = "argcomplete-3.6.3.tar.gz", hash = "sha256:62e8ed4fd6a45864acc8235409461b72c9a28ee785a2011cc5eb78318786c89c"},
]
[package.extras]
test = ["coverage", "mypy", "pexpect", "ruff", "wheel"]
[[package]] [[package]]
name = "cfgv" name = "cfgv"
version = "3.4.0" version = "3.4.0"
@@ -488,4 +502,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = ">=3.10,<4.0" python-versions = ">=3.10,<4.0"
content-hash = "98acd9fd57ec90c98a407b83122fd9c8ed432383e095a47d44e201bf187d3107" content-hash = "c7b7c37f18023c55d23bd61dad81cc93600a14a17fbed43958afaac369a52f91"

View File

@@ -1,16 +1,17 @@
[tool.poetry] [tool.poetry]
name = "mirro" name = "mirro"
version = "0.4.0" version = "0.6.0"
description = "A safe editing wrapper: edits a temp copy, compares, and saves original backup if changed." description = "A safe editing wrapper: edits a temp copy, compares, and saves original backup if changed."
authors = ["Marco D'Aleo <marco@marcodaleo.com>"] authors = ["Marco D'Aleo <marco@marcodaleo.com>"]
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
readme = "README.md" readme = "README.md"
homepage = "https://github.com/mdaleo404/mirro" homepage = "https://git.sysmd.uk/guardutils/mirro"
repository = "https://github.com/mdaleo404/mirro" repository = "https://git.sysmd.uk/guardutils/mirro"
packages = [{include = "mirro", from = "src"}] packages = [{include = "mirro", from = "src"}]
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = ">=3.10,<4.0" python = ">=3.10,<4.0"
argcomplete = ">=2"
[tool.poetry.scripts] [tool.poetry.scripts]
mirro = "mirro.main:main" mirro = "mirro.main:main"

View File

@@ -1,5 +1,6 @@
import importlib.metadata import importlib.metadata
import argparse import argparse
import argcomplete
import tempfile import tempfile
import subprocess import subprocess
import os import os
@@ -75,6 +76,19 @@ def strip_mirro_header(text: str) -> str:
return "".join(lines[i:]) return "".join(lines[i:])
def extract_original_path(backup_text: str) -> Path | None:
"""
Extract the original file path from a mirro backup header.
"""
for line in backup_text.splitlines():
if line.startswith("# Original file:"):
path = line.split(":", 1)[1].strip()
return Path(path).expanduser()
if line.strip() == "":
break
return None
def main(): def main():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Safely edit a file with automatic original backup if changed." description="Safely edit a file with automatic original backup if changed."
@@ -106,6 +120,13 @@ def main():
help="Restore the last backup of the given file and exit", help="Restore the last backup of the given file and exit",
) )
parser.add_argument(
"--restore",
metavar="BACKUP",
type=str,
help="Restore the given backup file and exit",
)
parser.add_argument( parser.add_argument(
"--prune-backups", "--prune-backups",
nargs="?", nargs="?",
@@ -120,6 +141,14 @@ def main():
help="Show a unified diff between FILE and BACKUP and exit", help="Show a unified diff between FILE and BACKUP and exit",
) )
parser.add_argument(
"--status",
action="store_true",
help="Show which files in the current directory have 'revisions'",
)
argcomplete.autocomplete(parser)
# Parse only options. Leave everything else untouched. # Parse only options. Leave everything else untouched.
args, positional = parser.parse_known_args() args, positional = parser.parse_known_args()
@@ -249,6 +278,52 @@ def main():
return return
if args.status:
backup_dir = Path(args.backup_dir).expanduser().resolve()
cwd = Path.cwd()
if not backup_dir.exists():
print(f"No mirro backups found in {cwd}.")
return 0
# Build map: filename -> list of backups
backup_map = {}
for b in backup_dir.iterdir():
name = b.name
if ".orig." not in name:
continue
filename, _, _ = name.partition(".orig.")
backup_map.setdefault(filename, []).append(b)
# Find files in current dir that have backups
entries = []
for file in cwd.iterdir():
if file.is_file() and file.name in backup_map:
backups = backup_map[file.name]
backups_sorted = sorted(
backups, key=lambda x: x.stat().st_mtime, reverse=True
)
latest = backups_sorted[0]
latest_mtime = time.strftime(
"%Y-%m-%d %H:%M:%S UTC",
time.gmtime(latest.stat().st_mtime),
)
entries.append((file.name, len(backups), latest_mtime))
# Nothing found?
if not entries:
print(f"No mirro backups found in {cwd}.")
return 0
# Otherwise print nice report
print(f"Files with history in {cwd}:")
for name, count, latest in entries:
print(f" {name:16} ({count} revision(s), latest: {latest})")
return 0
if args.restore_last: if args.restore_last:
backup_dir = Path(args.backup_dir).expanduser().resolve() backup_dir = Path(args.backup_dir).expanduser().resolve()
target = Path(args.restore_last).expanduser().resolve() target = Path(args.restore_last).expanduser().resolve()
@@ -265,7 +340,7 @@ def main():
] ]
if not backups: if not backups:
print(f"No backups found for {target}") print(f"No history found for {target}")
return 1 return 1
# newest backup # newest backup
@@ -279,6 +354,45 @@ def main():
print(f"Restored {target} from backup {last.name}") print(f"Restored {target} from backup {last.name}")
return return
if args.restore:
backup_arg = args.restore
backup_dir = Path(args.backup_dir).expanduser().resolve()
# Resolve backup path
if os.path.isabs(backup_arg) or backup_arg.startswith("~"):
backup_path = Path(backup_arg).expanduser().resolve()
else:
backup_path = backup_dir / backup_arg
if not backup_path.exists():
print(f"Backup not found: {backup_path}")
return 1
raw = backup_path.read_text(encoding="utf-8", errors="replace")
target = extract_original_path(raw)
if not target:
print(
"Could not determine original file location from backup header."
)
return 1
restored_text = strip_mirro_header(raw)
# Permission checks
if target.exists() and not os.access(target, os.W_OK):
print(f"Need elevated privileges to restore {target}")
return 1
if not target.exists() and not os.access(target.parent, os.W_OK):
print(f"Need elevated privileges to create {target}")
return 1
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(restored_text, encoding="utf-8")
print(f"Restored {target} from backup {backup_path.name}")
return 0
if args.prune_backups is not None: if args.prune_backups is not None:
mode = args.prune_backups mode = args.prune_backups

View File

@@ -72,7 +72,7 @@ def test_strip_header_removes_header():
def test_strip_header_preserves_shebang(): def test_strip_header_preserves_shebang():
text = "#!/usr/bin/env python3\nprint('hi')\n" text = "#!/usr/bin/env python3\nprint('hi')\n"
out = mirro.strip_mirro_header(text) out = mirro.strip_mirro_header(text)
assert out == text # unchanged assert out == text
def test_strip_header_non_header_file(): def test_strip_header_non_header_file():
@@ -297,29 +297,6 @@ def test_main_permission_denied_create(tmp_path, monkeypatch, capsys):
assert "Need elevated privileges to create" in capsys.readouterr().out assert "Need elevated privileges to create" in capsys.readouterr().out
# ============================================================
# Editor ordering: non-nano branch
# ============================================================
def test_main_editor_non_nano(tmp_path, monkeypatch, capsys):
target = tmp_path / "vim.txt"
target.write_text("old\n")
def fake_call(cmd):
temp = Path(cmd[1])
temp.write_text("edited\n")
monkeypatch.setenv("EDITOR", "vim")
monkeypatch.setattr(subprocess, "call", fake_call)
monkeypatch.setattr(os, "access", lambda p, m: True)
with patch("sys.argv", ["mirro", str(target)]):
mirro.main()
assert target.read_text() == "edited\n"
# ============================================================ # ============================================================
# --list # --list
# ============================================================ # ============================================================
@@ -376,8 +353,10 @@ def test_restore_last_no_backups(tmp_path, capsys):
): ):
result = mirro.main() result = mirro.main()
out = capsys.readouterr().out
assert result == 1 assert result == 1
assert "No backups found" in capsys.readouterr().out assert "No history found for" in out
assert str(target) in out
def test_restore_last_success(tmp_path, capsys): def test_restore_last_success(tmp_path, capsys):
@@ -401,7 +380,6 @@ def test_restore_last_success(tmp_path, capsys):
b1.write_text(mirro_header + "old1") b1.write_text(mirro_header + "old1")
b2.write_text(mirro_header + "old2") b2.write_text(mirro_header + "old2")
# ensure newest
os.utime(b2, (time.time(), time.time())) os.utime(b2, (time.time(), time.time()))
with patch( with patch(
@@ -415,153 +393,135 @@ def test_restore_last_success(tmp_path, capsys):
# ============================================================ # ============================================================
# --prune-backups # --restore (new)
# ============================================================ # ============================================================
def test_prune_all(tmp_path, capsys): def test_restore_backup_to_original_location(tmp_path, capsys):
d = tmp_path / "bk" backup_dir = tmp_path / "bk"
d.mkdir() backup_dir.mkdir()
(d / "a").write_text("x")
(d / "b").write_text("y")
with patch( original_dir = tmp_path / "orig"
"sys.argv", ["mirro", "--prune-backups=all", "--backup-dir", str(d)] original_dir.mkdir()
):
mirro.main()
out = capsys.readouterr().out target = original_dir / "file.txt"
assert "Removed ALL backups" in out
assert not any(d.iterdir())
mirro_header = (
def test_prune_numeric(tmp_path, capsys):
d = tmp_path / "bk"
d.mkdir()
old = d / "old"
new = d / "new"
old.write_text("x")
new.write_text("y")
one_day_seconds = 86400
os.utime(
old,
(
time.time() - one_day_seconds * 10,
time.time() - one_day_seconds * 10,
),
)
os.utime(new, None)
with patch(
"sys.argv", ["mirro", "--prune-backups=5", "--backup-dir", str(d)]
):
mirro.main()
out = capsys.readouterr().out
assert "Removed 1" in out
assert new.exists()
assert not old.exists()
def test_prune_default_env(tmp_path, monkeypatch, capsys):
monkeypatch.setenv("MIRRO_BACKUPS_LIFE", "1")
d = tmp_path / "bk"
d.mkdir()
f = d / "x"
f.write_text("hi")
os.utime(f, (time.time() - 86400 * 2, time.time() - 86400 * 2))
with patch(
"sys.argv", ["mirro", "--prune-backups", "--backup-dir", str(d)]
):
mirro.main()
assert "Removed 1" in capsys.readouterr().out
def test_prune_invalid_env(tmp_path, monkeypatch, capsys):
monkeypatch.setenv("MIRRO_BACKUPS_LIFE", "nope")
d = tmp_path / "bk"
d.mkdir()
with patch(
"sys.argv", ["mirro", "--prune-backups", "--backup-dir", str(d)]
):
mirro.main()
out = capsys.readouterr().out
assert "Invalid MIRRO_BACKUPS_LIFE value" in out
def test_prune_invalid_arg(tmp_path, capsys):
with patch("sys.argv", ["mirro", "--prune-backups=zzz"]):
result = mirro.main()
assert result == 1
assert "Invalid value for --prune-backups" in capsys.readouterr().out
# ============================================================
# --diff tests
# ============================================================
def test_diff_basic(tmp_path, capsys):
d = tmp_path / "bk"
d.mkdir()
file = tmp_path / "t.txt"
file.write_text("line1\nline2\n")
backup = d / "t.txt.orig.20250101T010203"
backup.write_text(
"# ---------------------------------------------------\n" "# ---------------------------------------------------\n"
"# mirro backup\n" "# mirro backup\n"
"# whatever\n" f"# Original file: {target}\n"
"# Timestamp: test\n"
"# Delete this header if you want to restore the file\n"
"# ---------------------------------------------------\n"
"\n" "\n"
"line1\nold\n" )
backup = backup_dir / "file.txt.orig.20250101T010203"
backup.write_text(mirro_header + "restored content\n")
with patch(
"sys.argv",
["mirro", "--restore", backup.name, "--backup-dir", str(backup_dir)],
):
result = mirro.main()
assert result == 0
assert target.exists()
assert target.read_text() == "restored content\n"
assert "Restored" in capsys.readouterr().out
def test_restore_missing_backup(tmp_path, capsys):
backup_dir = tmp_path / "bk"
backup_dir.mkdir()
with patch(
"sys.argv",
[
"mirro",
"--restore",
"nope.orig.123",
"--backup-dir",
str(backup_dir),
],
):
result = mirro.main()
assert result == 1
assert "Backup not found" in capsys.readouterr().out
def test_restore_missing_original_path_in_header(tmp_path, capsys):
backup_dir = tmp_path / "bk"
backup_dir.mkdir()
bad_backup = backup_dir / "x.txt.orig.123"
bad_backup.write_text(
"# ---------------------------------------------------\n"
"# mirro backup\n"
"# Timestamp: test\n"
"\n"
"data\n"
) )
with patch( with patch(
"sys.argv", "sys.argv",
["mirro", "--diff", str(file), backup.name, "--backup-dir", str(d)], [
"mirro",
"--restore",
bad_backup.name,
"--backup-dir",
str(backup_dir),
],
): ):
mirro.main() result = mirro.main()
out = capsys.readouterr().out assert result == 1
assert (
assert "--- a/t.txt" in out "Could not determine original file location" in capsys.readouterr().out
assert "+++ b/t.txt" in out )
assert "@@" in out
assert "-old" in out
assert "+line2" in out
def test_diff_wrong_backup_name_rejected(tmp_path, capsys): # ============================================================
d = tmp_path / "bk" # --status
d.mkdir() # ============================================================
file = tmp_path / "foo.txt"
file.write_text("hello\n")
bad = d / "bar.txt.orig.20250101T010203" def test_status_no_backups(tmp_path, monkeypatch, capsys):
bad.write_text("stuff\n") monkeypatch.chdir(tmp_path)
backup_dir = tmp_path / "bk"
with patch( with patch(
"sys.argv", "sys.argv", ["mirro", "--status", "--backup-dir", str(backup_dir)]
["mirro", "--diff", str(file), bad.name, "--backup-dir", str(d)],
): ):
result = mirro.main() result = mirro.main()
out = capsys.readouterr().out out = capsys.readouterr().out
assert result == 0
assert f"No mirro backups found in {tmp_path}" in out
assert result == 1
assert "does not match the file being diffed" in out def test_status_backups_found(tmp_path, monkeypatch, capsys):
assert "foo.txt.orig." in out monkeypatch.chdir(tmp_path)
cwd = tmp_path
backup_dir = tmp_path / "bk"
backup_dir.mkdir()
(tmp_path / "a.txt").write_text("data1")
(tmp_path / "b.txt").write_text("data2")
(backup_dir / "a.txt.orig.1").write_text("x")
(backup_dir / "a.txt.orig.2").write_text("y")
(backup_dir / "b.txt.orig.3").write_text("z")
with patch(
"sys.argv", ["mirro", "--status", "--backup-dir", str(backup_dir)]
):
result = mirro.main()
out = capsys.readouterr().out
assert result == 0
assert f"Files with history in {cwd}:" in out
assert "a.txt" in out
assert "b.txt" in out