8 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
4 changed files with 199 additions and 197 deletions

View File

@@ -1,8 +1,6 @@
[![License](https://img.shields.io/github/license/guardutils/mirro?style=flat)](LICENCE) [![Licence](https://img.shields.io/badge/GPL--3.0-orange?label=Licence)](https://git.sysmd.uk/guardutils/mirro/src/branch/main/LICENCE)
[![Language](https://img.shields.io/github/languages/top/guardutils/mirro.svg)](https://github.com/guardutils/mirro/) [![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)
[![GitHub Release](https://img.shields.io/github/v/release/guardutils/mirro?display_name=release&logo=github)](https://github.com/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)
[![PyPI - Version](https://img.shields.io/pypi/v/mirro?logo=pypi)](https://pypi.org/project/mirro/#history)
[![PyPI downloads](https://img.shields.io/pypi/dm/mirro.svg)](https://pypi.org/project/mirro/)
# mirro # mirro
@@ -106,6 +104,13 @@ 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.
``` ```
@@ -160,20 +165,60 @@ Files with history in /foo/bar:
## 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
```
#### 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 sudo dnf install mirro
``` ```
@@ -196,7 +241,7 @@ pip install mirro
### From this repository ### From this repository
``` ```
git clone https://github.com/guardutils/mirro.git git clone https://git.sysmd.uk/guardutils/mirro.git
cd mirro/ cd mirro/
poetry install poetry install
``` ```

View File

@@ -1,12 +1,12 @@
[tool.poetry] [tool.poetry]
name = "mirro" name = "mirro"
version = "0.5.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/guardutils/mirro" homepage = "https://git.sysmd.uk/guardutils/mirro"
repository = "https://github.com/guardutils/mirro" repository = "https://git.sysmd.uk/guardutils/mirro"
packages = [{include = "mirro", from = "src"}] packages = [{include = "mirro", from = "src"}]
[tool.poetry.dependencies] [tool.poetry.dependencies]

View File

@@ -76,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."
@@ -107,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="?",
@@ -334,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,156 +393,93 @@ 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",
mirro.main() "--restore",
bad_backup.name,
out = capsys.readouterr().out "--backup-dir",
str(backup_dir),
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() result = mirro.main()
out = capsys.readouterr().out
assert result == 1 assert result == 1
assert "does not match the file being diffed" in out assert (
assert "foo.txt.orig." in out "Could not determine original file location" in capsys.readouterr().out
)
# ============================================================ # ============================================================
@@ -575,7 +490,7 @@ def test_diff_wrong_backup_name_rejected(tmp_path, capsys):
def test_status_no_backups(tmp_path, monkeypatch, capsys): def test_status_no_backups(tmp_path, monkeypatch, capsys):
monkeypatch.chdir(tmp_path) monkeypatch.chdir(tmp_path)
backup_dir = tmp_path / "bk" # no backups inside backup_dir = tmp_path / "bk"
with patch( with patch(
"sys.argv", ["mirro", "--status", "--backup-dir", str(backup_dir)] "sys.argv", ["mirro", "--status", "--backup-dir", str(backup_dir)]
): ):
@@ -593,25 +508,12 @@ def test_status_backups_found(tmp_path, monkeypatch, capsys):
backup_dir = tmp_path / "bk" backup_dir = tmp_path / "bk"
backup_dir.mkdir() backup_dir.mkdir()
# Files in current directory (tmp_path / "a.txt").write_text("data1")
f1 = tmp_path / "a.txt" (tmp_path / "b.txt").write_text("data2")
f2 = tmp_path / "b.txt"
f1.write_text("data1")
f2.write_text("data2")
# Backups (backup_dir / "a.txt.orig.1").write_text("x")
(backup_dir / "a.txt.orig.20200101T000000").write_text("backup1") (backup_dir / "a.txt.orig.2").write_text("y")
(backup_dir / "a.txt.orig.20200101T010000").write_text("backup2") (backup_dir / "b.txt.orig.3").write_text("z")
(backup_dir / "b.txt.orig.20200202T020000").write_text("backup3")
# mtimes
t1 = time.time() - 200
t2 = time.time() - 100
t3 = time.time() - 50
os.utime(backup_dir / "a.txt.orig.20200101T000000", (t1, t1))
os.utime(backup_dir / "a.txt.orig.20200101T010000", (t2, t2))
os.utime(backup_dir / "b.txt.orig.20200202T020000", (t3, t3))
with patch( with patch(
"sys.argv", ["mirro", "--status", "--backup-dir", str(backup_dir)] "sys.argv", ["mirro", "--status", "--backup-dir", str(backup_dir)]
@@ -619,11 +521,7 @@ def test_status_backups_found(tmp_path, monkeypatch, capsys):
result = mirro.main() result = mirro.main()
out = capsys.readouterr().out out = capsys.readouterr().out
assert result == 0 assert result == 0
assert f"Backed-up files in {cwd}" in out assert f"Files with history in {cwd}:" in out
assert "a.txt" in out assert "a.txt" in out
assert "b.txt" in out assert "b.txt" in out
assert "(2 backup(s)," in out
assert "(1 backup(s)," in out
assert "UTC" in out