Compare commits
10 Commits
update_mir
...
update_mir
| Author | SHA1 | Date | |
|---|---|---|---|
| e66bdc9a8f | |||
| 598a71dcc8 | |||
| 385d721155 | |||
| 1cb0bca865 | |||
|
|
6dbadc476a | ||
| 5f9d8a81d4 | |||
| 60ff38e207 | |||
|
|
19f285db9c | ||
| 8cf2a5f1ac | |||
|
|
7cb2c3adb2 |
@@ -1,7 +1,7 @@
|
||||
name: CI
|
||||
name: Lint & Security
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
precommit-and-security:
|
||||
30
README.md
30
README.md
@@ -128,25 +128,49 @@ Invalid or non-numeric values fall back to 30 days.
|
||||
|
||||
**Note:** _a value of 0 is **invalid**_.
|
||||
|
||||
### Built-in diff
|
||||
This shows a _git-like_ diff of the current file version and any of that file backups.
|
||||
```
|
||||
mirro --diff file file.orig.20251121T163121
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
### From package manager
|
||||
|
||||
This is the preferred method of installation.
|
||||
|
||||
**Ubuntu 22.04 and 24.04**
|
||||
```
|
||||
sudo add-apt-repository ppa:mdaleo/mirro
|
||||
sudo apt update
|
||||
sudo apt install mirro
|
||||
```
|
||||
|
||||
**Fedora 41, 42, 43**
|
||||
```
|
||||
sudo dnf copr enable mdaleo/mirro
|
||||
sudo dnf install resrm
|
||||
```
|
||||
|
||||
### From PyPI
|
||||
|
||||
**NOTE:** To use `mirro` with `sudo`, the path to `mirro` must be in the `$PATH` seen by `root`.\
|
||||
Either:
|
||||
|
||||
* install `mirro` as `root` (_preferred_), or
|
||||
* install `mirro` as `root`, or
|
||||
* add the path to `mirro` to the `secure_path` parameter in `/etc/sudoers`. For example, where `/home/user/.local/bin` is where `mirro` is:
|
||||
|
||||
``` bash
|
||||
Defaults secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/home/user/.local/bin"
|
||||
```
|
||||
|
||||
Install via PyPI (preferred):
|
||||
Install with:
|
||||
```
|
||||
pip install mirro
|
||||
```
|
||||
|
||||
Or clone the repo and install locally:
|
||||
### From this repository
|
||||
```
|
||||
git clone https://github.com/mdaleo404/mirro.git
|
||||
cd mirro/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "mirro"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
description = "A safe editing wrapper: edits a temp copy, compares, and saves original backup if changed."
|
||||
authors = ["Marco D'Aleo <marco@marcodaleo.com>"]
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
@@ -4,6 +4,7 @@ import tempfile
|
||||
import subprocess
|
||||
import os
|
||||
import textwrap
|
||||
import difflib
|
||||
from pathlib import Path
|
||||
import time
|
||||
|
||||
@@ -49,6 +50,31 @@ def backup_original(
|
||||
return backup_path
|
||||
|
||||
|
||||
def strip_mirro_header(text: str) -> str:
|
||||
"""
|
||||
Strip only mirro's backup header (if present).
|
||||
Never removes shebangs or anything else.
|
||||
"""
|
||||
lines = text.splitlines(keepends=True)
|
||||
|
||||
# If there's no mirro header, return the text unchanged
|
||||
if not lines or not lines[0].startswith(
|
||||
"# ---------------------------------------------------"
|
||||
):
|
||||
return text
|
||||
|
||||
# Otherwise skip all header lines until the first blank line
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
if lines[i].strip() == "":
|
||||
i += 1 # skip the blank separator line
|
||||
break
|
||||
i += 1
|
||||
|
||||
# 'i' now points to the first real line of the original file
|
||||
return "".join(lines[i:])
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Safely edit a file with automatic original backup if changed."
|
||||
@@ -87,9 +113,85 @@ def main():
|
||||
help="Prune backups older than MIRRO_BACKUPS_LIFE days, or 'all' to delete all backups",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--diff",
|
||||
nargs=2,
|
||||
metavar=("FILE", "BACKUP"),
|
||||
help="Show a unified diff between FILE and BACKUP and exit",
|
||||
)
|
||||
|
||||
# Parse only options. Leave everything else untouched.
|
||||
args, positional = parser.parse_known_args()
|
||||
|
||||
if args.diff:
|
||||
file_arg, backup_arg = args.diff
|
||||
|
||||
file_path = Path(file_arg).expanduser().resolve()
|
||||
|
||||
# Resolve backup: if it’s not absolute or ~, treat it as a filename in the backup dir
|
||||
if os.path.isabs(backup_arg) or backup_arg.startswith("~"):
|
||||
backup_path = Path(backup_arg).expanduser().resolve()
|
||||
else:
|
||||
backup_dir = Path(args.backup_dir).expanduser().resolve()
|
||||
backup_path = backup_dir / backup_arg
|
||||
|
||||
if not file_path.exists():
|
||||
print(f"File not found: {file_path}")
|
||||
return 1
|
||||
if not backup_path.exists():
|
||||
print(f"Backup not found: {backup_path}")
|
||||
return 1
|
||||
|
||||
# Enforce same base filename while diffing
|
||||
target_name = file_path.name
|
||||
backup_name = backup_path.name
|
||||
|
||||
if not backup_name.startswith(target_name + ".orig."):
|
||||
print(
|
||||
f"Error: Backup '{backup_name}' does not match the file being diffed.\n"
|
||||
f"Expected backup file starting with: {target_name}.orig."
|
||||
)
|
||||
return 1
|
||||
|
||||
original = file_path.read_text(
|
||||
encoding="utf-8", errors="replace"
|
||||
).splitlines()
|
||||
backup_raw = backup_path.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
backup_stripped = strip_mirro_header(backup_raw)
|
||||
backup = backup_stripped.splitlines()
|
||||
|
||||
# Generate a clean diff (no trailing line noise)
|
||||
diff = difflib.unified_diff(
|
||||
backup,
|
||||
original,
|
||||
fromfile=f"a/{file_path.name}",
|
||||
tofile=f"b/{file_path.name}",
|
||||
lineterm="",
|
||||
)
|
||||
|
||||
# Colors
|
||||
RED = "\033[31m"
|
||||
GREEN = "\033[32m"
|
||||
CYAN = "\033[36m"
|
||||
RESET = "\033[0m"
|
||||
|
||||
for line in diff:
|
||||
if (
|
||||
line.startswith("---")
|
||||
or line.startswith("+++")
|
||||
or line.startswith("@@")
|
||||
):
|
||||
print(f"{CYAN}{line}{RESET}")
|
||||
elif line.startswith("+"):
|
||||
print(f"{GREEN}{line}{RESET}")
|
||||
elif line.startswith("-"):
|
||||
print(f"{RED}{line}{RESET}")
|
||||
else:
|
||||
print(line)
|
||||
|
||||
return
|
||||
|
||||
if args.list:
|
||||
import pwd, grp
|
||||
|
||||
@@ -171,26 +273,7 @@ def main():
|
||||
|
||||
# read and strip header
|
||||
raw = last.read_text(encoding="utf-8", errors="replace")
|
||||
restored = []
|
||||
skipping = True
|
||||
for line in raw.splitlines(keepends=True):
|
||||
# header ends at first blank line after the dashed line block
|
||||
if skipping:
|
||||
if line.strip() == "" and restored == []:
|
||||
# allow only after header
|
||||
continue
|
||||
if line.startswith("#") or line.strip() == "":
|
||||
continue
|
||||
skipping = False
|
||||
restored.append(line)
|
||||
|
||||
# if header wasn't present, restored = raw
|
||||
if not restored:
|
||||
restored_text = raw
|
||||
else:
|
||||
restored_text = "".join(restored)
|
||||
|
||||
# write the restored file back
|
||||
restored_text = strip_mirro_header(raw)
|
||||
target.write_text(restored_text, encoding="utf-8")
|
||||
|
||||
print(f"Restored {target} from backup {last.name}")
|
||||
|
||||
@@ -48,17 +48,49 @@ def test_write_file(tmp_path):
|
||||
assert p.read_text(encoding="utf-8") == "data"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# strip_mirro_header
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_strip_header_removes_header():
|
||||
header_text = (
|
||||
"# ---------------------------------------------------\n"
|
||||
"# mirro backup\n"
|
||||
"# something\n"
|
||||
"# ---------------------------------------------------\n"
|
||||
"\n"
|
||||
"#!/bin/bash\n"
|
||||
"echo hi\n"
|
||||
)
|
||||
|
||||
out = mirro.strip_mirro_header(header_text)
|
||||
assert out.startswith("#!/bin/bash")
|
||||
assert "mirro backup" not in out
|
||||
|
||||
|
||||
def test_strip_header_preserves_shebang():
|
||||
text = "#!/usr/bin/env python3\nprint('hi')\n"
|
||||
out = mirro.strip_mirro_header(text)
|
||||
assert out == text # unchanged
|
||||
|
||||
|
||||
def test_strip_header_non_header_file():
|
||||
text = "# just a comment\nvalue\n"
|
||||
out = mirro.strip_mirro_header(text)
|
||||
assert out == text
|
||||
|
||||
|
||||
# ============================================================
|
||||
# backup_original
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_backup_original(tmp_path, monkeypatch):
|
||||
original_path = tmp_path / "test.txt"
|
||||
original_path = tmp_path / "a.txt"
|
||||
original_content = "ABC"
|
||||
backup_dir = tmp_path / "backups"
|
||||
|
||||
# Freeze timestamps
|
||||
monkeypatch.setattr(
|
||||
time,
|
||||
"gmtime",
|
||||
@@ -78,14 +110,14 @@ def test_backup_original(tmp_path, monkeypatch):
|
||||
)
|
||||
|
||||
assert backup_path.exists()
|
||||
text = backup_path.read_text(encoding="utf-8")
|
||||
text = backup_path.read_text()
|
||||
assert "mirro backup" in text
|
||||
assert "Original file:" in text
|
||||
assert original_content in text
|
||||
assert "Original file" in text
|
||||
assert "ABC" in text
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Helper to run main()
|
||||
# Helper to simulate main()
|
||||
# ============================================================
|
||||
|
||||
|
||||
@@ -100,11 +132,8 @@ def simulate_main(
|
||||
file_exists=True,
|
||||
override_access=None,
|
||||
):
|
||||
"""Utility to simulate mirro.main()"""
|
||||
|
||||
monkeypatch.setenv("EDITOR", editor)
|
||||
|
||||
# Fake editor
|
||||
def fake_call(cmd):
|
||||
temp = Path(cmd[-1])
|
||||
if edited_content is None:
|
||||
@@ -115,13 +144,11 @@ def simulate_main(
|
||||
|
||||
monkeypatch.setattr(subprocess, "call", fake_call)
|
||||
|
||||
# Access override if provided
|
||||
if override_access:
|
||||
monkeypatch.setattr(os, "access", override_access)
|
||||
else:
|
||||
monkeypatch.setattr(os, "access", lambda p, m: True)
|
||||
|
||||
# Set up file as needed
|
||||
target = Path(args[-1]).expanduser().resolve()
|
||||
if file_exists:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -135,7 +162,7 @@ def simulate_main(
|
||||
|
||||
|
||||
# ============================================================
|
||||
# main: missing file argument
|
||||
# main: missing positional file
|
||||
# ============================================================
|
||||
|
||||
|
||||
@@ -143,24 +170,23 @@ def test_main_missing_argument(capsys):
|
||||
with patch("sys.argv", ["mirro"]):
|
||||
with pytest.raises(SystemExit):
|
||||
mirro.main()
|
||||
|
||||
assert (
|
||||
"the following arguments are required: file" in capsys.readouterr().err
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# main: unchanged file (line 137)
|
||||
# main: unchanged file
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_main_existing_unchanged(tmp_path, monkeypatch, capsys):
|
||||
target = tmp_path / "file.txt"
|
||||
target.write_text("hello\n", encoding="utf-8")
|
||||
target.write_text("hello\n")
|
||||
|
||||
def fake_call(cmd):
|
||||
temp = Path(cmd[-1])
|
||||
temp.write_text("hello\n", encoding="utf-8")
|
||||
temp.write_text("hello\n")
|
||||
|
||||
monkeypatch.setenv("EDITOR", "nano")
|
||||
monkeypatch.setattr(subprocess, "call", fake_call)
|
||||
@@ -169,8 +195,7 @@ def test_main_existing_unchanged(tmp_path, monkeypatch, capsys):
|
||||
with patch("sys.argv", ["mirro", str(target)]):
|
||||
mirro.main()
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "file hasn't changed" in out
|
||||
assert "file hasn't changed" in capsys.readouterr().out
|
||||
|
||||
|
||||
# ============================================================
|
||||
@@ -179,7 +204,7 @@ def test_main_existing_unchanged(tmp_path, monkeypatch, capsys):
|
||||
|
||||
|
||||
def test_main_existing_changed(tmp_path, monkeypatch, capsys):
|
||||
target = tmp_path / "file2.txt"
|
||||
target = tmp_path / "f2.txt"
|
||||
|
||||
result, out = simulate_main(
|
||||
monkeypatch,
|
||||
@@ -191,7 +216,7 @@ def test_main_existing_changed(tmp_path, monkeypatch, capsys):
|
||||
)
|
||||
|
||||
assert "file changed; original backed up at" in out
|
||||
assert target.read_text(encoding="utf-8") == "new\n"
|
||||
assert target.read_text() == "new\n"
|
||||
|
||||
|
||||
# ============================================================
|
||||
@@ -233,68 +258,57 @@ def test_main_new_file_changed(tmp_path, monkeypatch, capsys):
|
||||
)
|
||||
|
||||
assert "file changed; original backed up at" in out
|
||||
assert new.read_text(encoding="utf-8") == "XYZ\n"
|
||||
assert new.read_text() == "XYZ\n"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# main: permission denied for existing file (line 78)
|
||||
# Permission denied branches
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_main_permission_denied_existing(tmp_path, monkeypatch, capsys):
|
||||
target = tmp_path / "blocked.txt"
|
||||
target.write_text("hello", encoding="utf-8")
|
||||
tgt = tmp_path / "blocked.txt"
|
||||
tgt.write_text("hi")
|
||||
|
||||
monkeypatch.setenv("EDITOR", "nano")
|
||||
monkeypatch.setattr(os, "access", lambda p, m: False)
|
||||
|
||||
with patch("sys.argv", ["mirro", str(target)]):
|
||||
with patch("sys.argv", ["mirro", str(tgt)]):
|
||||
result = mirro.main()
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "Need elevated privileges to open" in out
|
||||
assert result == 1
|
||||
|
||||
|
||||
# ============================================================
|
||||
# main: permission denied creating file (line 84)
|
||||
# ============================================================
|
||||
assert "Need elevated privileges to open" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_main_permission_denied_create(tmp_path, monkeypatch, capsys):
|
||||
newfile = tmp_path / "subdir" / "nofile.txt"
|
||||
parent = newfile.parent
|
||||
parent.mkdir(parents=True, exist_ok=True)
|
||||
new = tmp_path / "sub/xx.txt"
|
||||
new.parent.mkdir(parents=True)
|
||||
|
||||
# Directory is not writable
|
||||
def fake_access(path, mode):
|
||||
if path == parent:
|
||||
return False
|
||||
return True
|
||||
return False if path == new.parent else True
|
||||
|
||||
monkeypatch.setattr(os, "access", fake_access)
|
||||
monkeypatch.setenv("EDITOR", "nano")
|
||||
|
||||
with patch("sys.argv", ["mirro", str(newfile)]):
|
||||
with patch("sys.argv", ["mirro", str(new)]):
|
||||
result = mirro.main()
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "Need elevated privileges to create" in out
|
||||
assert result == 1
|
||||
assert "Need elevated privileges to create" in capsys.readouterr().out
|
||||
|
||||
|
||||
# ============================================================
|
||||
# main: non-nano editor (ordering branch)
|
||||
# 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", encoding="utf-8")
|
||||
target.write_text("old\n")
|
||||
|
||||
def fake_call(cmd):
|
||||
temp = Path(cmd[1]) # in non-nano mode
|
||||
temp.write_text("edited\n", encoding="utf-8")
|
||||
temp = Path(cmd[1])
|
||||
temp.write_text("edited\n")
|
||||
|
||||
monkeypatch.setenv("EDITOR", "vim")
|
||||
monkeypatch.setattr(subprocess, "call", fake_call)
|
||||
@@ -303,4 +317,251 @@ def test_main_editor_non_nano(tmp_path, monkeypatch, capsys):
|
||||
with patch("sys.argv", ["mirro", str(target)]):
|
||||
mirro.main()
|
||||
|
||||
assert target.read_text(encoding="utf-8") == "edited\n"
|
||||
assert target.read_text() == "edited\n"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# --list
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_main_list_no_dir(tmp_path, capsys):
|
||||
with patch(
|
||||
"sys.argv", ["mirro", "--list", "--backup-dir", str(tmp_path / "none")]
|
||||
):
|
||||
mirro.main()
|
||||
assert "No backups found." in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_main_list_entries(tmp_path, capsys):
|
||||
d = tmp_path / "bk"
|
||||
d.mkdir()
|
||||
(d / "a.txt.orig.1").write_text("x")
|
||||
(d / "b.txt.orig.2").write_text("y")
|
||||
|
||||
with patch("sys.argv", ["mirro", "--list", "--backup-dir", str(d)]):
|
||||
mirro.main()
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "a.txt.orig.1" in out
|
||||
assert "b.txt.orig.2" in out
|
||||
|
||||
|
||||
# ============================================================
|
||||
# --restore-last
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_restore_last_no_dir(tmp_path, capsys):
|
||||
d = tmp_path / "none"
|
||||
target = tmp_path / "x.txt"
|
||||
with patch(
|
||||
"sys.argv",
|
||||
["mirro", "--restore-last", str(target), "--backup-dir", str(d)],
|
||||
):
|
||||
result = mirro.main()
|
||||
|
||||
assert result == 1
|
||||
assert "No backup directory found." in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_restore_last_no_backups(tmp_path, capsys):
|
||||
d = tmp_path / "bk"
|
||||
d.mkdir()
|
||||
target = tmp_path / "t.txt"
|
||||
|
||||
with patch(
|
||||
"sys.argv",
|
||||
["mirro", "--restore-last", str(target), "--backup-dir", str(d)],
|
||||
):
|
||||
result = mirro.main()
|
||||
|
||||
assert result == 1
|
||||
assert "No backups found" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_restore_last_success(tmp_path, capsys):
|
||||
d = tmp_path / "bk"
|
||||
d.mkdir()
|
||||
target = tmp_path / "t.txt"
|
||||
|
||||
mirro_header = (
|
||||
"# ---------------------------------------------------\n"
|
||||
"# mirro backup\n"
|
||||
"# Original file: x\n"
|
||||
"# Timestamp: test\n"
|
||||
"# Delete this header if you want to restore the file\n"
|
||||
"# ---------------------------------------------------\n"
|
||||
"\n"
|
||||
)
|
||||
|
||||
b1 = d / "t.txt.orig.2020"
|
||||
b2 = d / "t.txt.orig.2021"
|
||||
|
||||
b1.write_text(mirro_header + "old1")
|
||||
b2.write_text(mirro_header + "old2")
|
||||
|
||||
# ensure newest
|
||||
os.utime(b2, (time.time(), time.time()))
|
||||
|
||||
with patch(
|
||||
"sys.argv",
|
||||
["mirro", "--restore-last", str(target), "--backup-dir", str(d)],
|
||||
):
|
||||
mirro.main()
|
||||
|
||||
assert target.read_text() == "old2"
|
||||
assert "Restored" in capsys.readouterr().out
|
||||
|
||||
|
||||
# ============================================================
|
||||
# --prune-backups
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_prune_all(tmp_path, capsys):
|
||||
d = tmp_path / "bk"
|
||||
d.mkdir()
|
||||
(d / "a").write_text("x")
|
||||
(d / "b").write_text("y")
|
||||
|
||||
with patch(
|
||||
"sys.argv", ["mirro", "--prune-backups=all", "--backup-dir", str(d)]
|
||||
):
|
||||
mirro.main()
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "Removed ALL backups" in out
|
||||
assert not any(d.iterdir())
|
||||
|
||||
|
||||
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"
|
||||
"# mirro backup\n"
|
||||
"# whatever\n"
|
||||
"\n"
|
||||
"line1\nold\n"
|
||||
)
|
||||
|
||||
with patch(
|
||||
"sys.argv",
|
||||
["mirro", "--diff", str(file), backup.name, "--backup-dir", str(d)],
|
||||
):
|
||||
mirro.main()
|
||||
|
||||
out = capsys.readouterr().out
|
||||
|
||||
assert "--- a/t.txt" in 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"
|
||||
d.mkdir()
|
||||
|
||||
file = tmp_path / "foo.txt"
|
||||
file.write_text("hello\n")
|
||||
|
||||
bad = d / "bar.txt.orig.20250101T010203"
|
||||
bad.write_text("stuff\n")
|
||||
|
||||
with patch(
|
||||
"sys.argv",
|
||||
["mirro", "--diff", str(file), bad.name, "--backup-dir", str(d)],
|
||||
):
|
||||
result = mirro.main()
|
||||
|
||||
out = capsys.readouterr().out
|
||||
|
||||
assert result == 1
|
||||
assert "does not match the file being diffed" in out
|
||||
assert "foo.txt.orig." in out
|
||||
|
||||
Reference in New Issue
Block a user