diff --git a/README.md b/README.md index 945437c..fcc17f3 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,11 @@ 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 diff --git a/pyproject.toml b/pyproject.toml index 7ebe5c2..44e461f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 "] license = "GPL-3.0-or-later" diff --git a/src/mirro/main.py b/src/mirro/main.py index 29b28fa..7c7c2e5 100644 --- a/src/mirro/main.py +++ b/src/mirro/main.py @@ -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}") diff --git a/tests/test_mirro.py b/tests/test_mirro.py index fbb4753..f202bc7 100644 --- a/tests/test_mirro.py +++ b/tests/test_mirro.py @@ -48,6 +48,39 @@ 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 # ============================================================ @@ -352,11 +385,21 @@ def test_restore_last_success(tmp_path, capsys): 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("# header\n\nold1") - b2.write_text("# header\n\nold2") + b1.write_text(mirro_header + "old1") + b2.write_text(mirro_header + "old2") # ensure newest os.utime(b2, (time.time(), time.time())) @@ -392,7 +435,7 @@ def test_prune_all(tmp_path, capsys): assert not any(d.iterdir()) -def test_prune_numeric(tmp_path, capsys, monkeypatch): +def test_prune_numeric(tmp_path, capsys): d = tmp_path / "bk" d.mkdir() @@ -418,7 +461,7 @@ def test_prune_numeric(tmp_path, capsys, monkeypatch): mirro.main() out = capsys.readouterr().out - assert "Removed 1 backup" in out + assert "Removed 1" in out assert new.exists() assert not old.exists() @@ -463,3 +506,62 @@ def test_prune_invalid_arg(tmp_path, capsys): 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