Merge pull request #9 from mdaleo404/update_mirro_20251121
Update mirro 20251121
This commit was merged in pull request #9.
This commit is contained in:
@@ -128,6 +128,11 @@ 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
|
||||||
|
This shows a _git-like_ diff of the current file version and any of that file backups.
|
||||||
|
```
|
||||||
|
mirro --diff file file.orig.20251121T163121
|
||||||
|
```
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "mirro"
|
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."
|
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"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import tempfile
|
|||||||
import subprocess
|
import subprocess
|
||||||
import os
|
import os
|
||||||
import textwrap
|
import textwrap
|
||||||
|
import difflib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import time
|
import time
|
||||||
|
|
||||||
@@ -49,6 +50,31 @@ def backup_original(
|
|||||||
return backup_path
|
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():
|
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."
|
||||||
@@ -87,9 +113,85 @@ def main():
|
|||||||
help="Prune backups older than MIRRO_BACKUPS_LIFE days, or 'all' to delete all backups",
|
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.
|
# Parse only options. Leave everything else untouched.
|
||||||
args, positional = parser.parse_known_args()
|
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:
|
if args.list:
|
||||||
import pwd, grp
|
import pwd, grp
|
||||||
|
|
||||||
@@ -171,26 +273,7 @@ def main():
|
|||||||
|
|
||||||
# read and strip header
|
# read and strip header
|
||||||
raw = last.read_text(encoding="utf-8", errors="replace")
|
raw = last.read_text(encoding="utf-8", errors="replace")
|
||||||
restored = []
|
restored_text = strip_mirro_header(raw)
|
||||||
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
|
|
||||||
target.write_text(restored_text, encoding="utf-8")
|
target.write_text(restored_text, encoding="utf-8")
|
||||||
|
|
||||||
print(f"Restored {target} from backup {last.name}")
|
print(f"Restored {target} from backup {last.name}")
|
||||||
|
|||||||
@@ -48,6 +48,39 @@ def test_write_file(tmp_path):
|
|||||||
assert p.read_text(encoding="utf-8") == "data"
|
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
|
# backup_original
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -352,11 +385,21 @@ def test_restore_last_success(tmp_path, capsys):
|
|||||||
d.mkdir()
|
d.mkdir()
|
||||||
target = tmp_path / "t.txt"
|
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"
|
b1 = d / "t.txt.orig.2020"
|
||||||
b2 = d / "t.txt.orig.2021"
|
b2 = d / "t.txt.orig.2021"
|
||||||
|
|
||||||
b1.write_text("# header\n\nold1")
|
b1.write_text(mirro_header + "old1")
|
||||||
b2.write_text("# header\n\nold2")
|
b2.write_text(mirro_header + "old2")
|
||||||
|
|
||||||
# ensure newest
|
# ensure newest
|
||||||
os.utime(b2, (time.time(), time.time()))
|
os.utime(b2, (time.time(), time.time()))
|
||||||
@@ -392,7 +435,7 @@ def test_prune_all(tmp_path, capsys):
|
|||||||
assert not any(d.iterdir())
|
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 = tmp_path / "bk"
|
||||||
d.mkdir()
|
d.mkdir()
|
||||||
|
|
||||||
@@ -418,7 +461,7 @@ def test_prune_numeric(tmp_path, capsys, monkeypatch):
|
|||||||
mirro.main()
|
mirro.main()
|
||||||
|
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
assert "Removed 1 backup" in out
|
assert "Removed 1" in out
|
||||||
assert new.exists()
|
assert new.exists()
|
||||||
assert not old.exists()
|
assert not old.exists()
|
||||||
|
|
||||||
@@ -463,3 +506,62 @@ def test_prune_invalid_arg(tmp_path, capsys):
|
|||||||
|
|
||||||
assert result == 1
|
assert result == 1
|
||||||
assert "Invalid value for --prune-backups" in capsys.readouterr().out
|
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