From 0dc9b827f919e15473025a95ffb2672e4cb5af12 Mon Sep 17 00:00:00 2001 From: Marco D'Aleo Date: Mon, 15 Dec 2025 17:18:40 +0000 Subject: [PATCH 1/3] Add restore (any) feature --- src/mirro/main.py | 59 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/src/mirro/main.py b/src/mirro/main.py index 00878ad..e599eeb 100644 --- a/src/mirro/main.py +++ b/src/mirro/main.py @@ -76,6 +76,19 @@ def strip_mirro_header(text: str) -> str: 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(): parser = argparse.ArgumentParser( 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", ) + parser.add_argument( + "--restore", + metavar="BACKUP", + type=str, + help="Restore the given backup file and exit", + ) + parser.add_argument( "--prune-backups", nargs="?", @@ -334,6 +354,45 @@ def main(): print(f"Restored {target} from backup {last.name}") 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: mode = args.prune_backups -- 2.49.1 From 537397ac36bc572d966769b8c0271e2a993e9eab Mon Sep 17 00:00:00 2001 From: Marco D'Aleo Date: Mon, 15 Dec 2025 17:19:17 +0000 Subject: [PATCH 2/3] Update tests with --restore flag --- tests/test_mirro.py | 264 ++++++++++++++------------------------------ 1 file changed, 81 insertions(+), 183 deletions(-) diff --git a/tests/test_mirro.py b/tests/test_mirro.py index 9508282..ff423b0 100644 --- a/tests/test_mirro.py +++ b/tests/test_mirro.py @@ -72,7 +72,7 @@ def test_strip_header_removes_header(): def test_strip_header_preserves_shebang(): text = "#!/usr/bin/env python3\nprint('hi')\n" out = mirro.strip_mirro_header(text) - assert out == text # unchanged + assert out == text 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 -# ============================================================ -# 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 # ============================================================ @@ -376,8 +353,10 @@ def test_restore_last_no_backups(tmp_path, capsys): ): result = mirro.main() + out = capsys.readouterr().out 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): @@ -401,7 +380,6 @@ def test_restore_last_success(tmp_path, capsys): b1.write_text(mirro_header + "old1") b2.write_text(mirro_header + "old2") - # ensure newest os.utime(b2, (time.time(), time.time())) 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): - d = tmp_path / "bk" - d.mkdir() - (d / "a").write_text("x") - (d / "b").write_text("y") +def test_restore_backup_to_original_location(tmp_path, capsys): + backup_dir = tmp_path / "bk" + backup_dir.mkdir() - with patch( - "sys.argv", ["mirro", "--prune-backups=all", "--backup-dir", str(d)] - ): - mirro.main() + original_dir = tmp_path / "orig" + original_dir.mkdir() - out = capsys.readouterr().out - assert "Removed ALL backups" in out - assert not any(d.iterdir()) + target = original_dir / "file.txt" - -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( + mirro_header = ( "# ---------------------------------------------------\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" - "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( "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)], + [ + "mirro", + "--restore", + bad_backup.name, + "--backup-dir", + str(backup_dir), + ], ): 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 + assert ( + "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): monkeypatch.chdir(tmp_path) - backup_dir = tmp_path / "bk" # no backups inside + backup_dir = tmp_path / "bk" with patch( "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.mkdir() - # Files in current directory - f1 = tmp_path / "a.txt" - f2 = tmp_path / "b.txt" - f1.write_text("data1") - f2.write_text("data2") + (tmp_path / "a.txt").write_text("data1") + (tmp_path / "b.txt").write_text("data2") - # Backups - (backup_dir / "a.txt.orig.20200101T000000").write_text("backup1") - (backup_dir / "a.txt.orig.20200101T010000").write_text("backup2") - (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)) + (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)] @@ -619,11 +521,7 @@ def test_status_backups_found(tmp_path, monkeypatch, capsys): result = mirro.main() out = capsys.readouterr().out - 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 "b.txt" in out - assert "(2 backup(s)," in out - assert "(1 backup(s)," in out - assert "UTC" in out -- 2.49.1 From 9adbb746022d3307a098bef99d9ff84da5f2e6b6 Mon Sep 17 00:00:00 2001 From: Marco D'Aleo Date: Mon, 15 Dec 2025 17:23:08 +0000 Subject: [PATCH 3/3] Update README, version bump 0.6.0 --- README.md | 7 +++++++ pyproject.toml | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a5496af..17dc3c5 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,13 @@ This: 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. ``` diff --git a/pyproject.toml b/pyproject.toml index 24ab7e8..d686b0d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] 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." authors = ["Marco D'Aleo "] license = "GPL-3.0-or-later" -- 2.49.1