15 Commits

Author SHA1 Message Date
e48adddd4c Add --status flag, TAB completion with argcomplete, update README, update tests 2025-12-04 15:10:03 +00:00
d2fff45db7 Update badges URLs 2025-11-29 16:41:47 +00:00
Marco D'Aleo
b2af78e643 Merge pull request #11 from guardutils/update_mirro_20251127
Switch ownership from mdaleo404 to guardutils in README and pyproject
2025-11-27 17:52:53 +00:00
ea7c1384a0 Switch ownership from mdaleo404 to guardutils in README and pyproject 2025-11-27 17:51:27 +00:00
Marco D'Aleo
e88a248463 Merge pull request #10 from mdaleo404/add_badges_to_readme
Add badges to README
2025-11-23 07:35:19 +00:00
755b69ce99 Add badges to README 2025-11-23 07:34:15 +00:00
Marco D'Aleo
9fa7fc1c52 Update README.md
Fix package name in README
2025-11-21 18:07:54 +00:00
Marco D'Aleo
d982509acb Merge pull request #9 from mdaleo404/update_mirro_20251121
Update mirro 20251121
2025-11-21 17:19:15 +00:00
e66bdc9a8f Update tests 2025-11-21 17:17:38 +00:00
598a71dcc8 Package version bump to 0.4.0 2025-11-21 17:07:44 +00:00
385d721155 Add diff functionality 2025-11-21 17:04:59 +00:00
1cb0bca865 Fix accidental removal of shebangs when restoring a script 2025-11-21 13:54:42 +00:00
Marco D'Aleo
6dbadc476a Merge pull request #8 from mdaleo404/update_readme_with_copr_ppa
Update README with new installation methods
2025-11-17 19:06:05 +00:00
5f9d8a81d4 Update README with new installation methods 2025-11-17 19:03:35 +00:00
60ff38e207 Rename workflow and make it trigger on pull requests 2025-11-17 15:12:01 +00:00
6 changed files with 412 additions and 35 deletions

View File

@@ -1,7 +1,7 @@
name: CI
name: Lint & Security
on:
push:
pull_request:
jobs:
precommit-and-security:

View File

@@ -1,3 +1,9 @@
[![License](https://img.shields.io/github/license/guardutils/mirro?style=flat)](LICENCE)
[![Language](https://img.shields.io/github/languages/top/guardutils/mirro.svg)](https://github.com/guardutils/mirro/)
[![GitHub Release](https://img.shields.io/github/v/release/guardutils/mirro?display_name=release&logo=github)](https://github.com/guardutils/mirro/releases)
[![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** is a tiny safety-first editing wrapper for text files.
@@ -82,12 +88,14 @@ filename.ext.orig.20251110T174400
## Functionalities
### List all backup files stored in your backup directory.
```
mirro --list
```
Output includes permissions, owner/group, timestamps, and backup filenames.
### Restore the most recent backup for a given file.
```
mirro --restore-last ~/.config/myapp/config.ini
```
@@ -99,24 +107,28 @@ This:
3. and overwrites the target file with its original contents.
### Remove old backup files.
```
mirro --prune-backups
```
This removes backups older than the number of days set in `MIRRO_BACKUPS_LIFE`.
### Remove backups older than _N_ days
```
mirro --prune-backups=14
```
This keeps the last 14 days of backups and removes everything older.
### Remove all backups
```
mirro --prune-backups=all
```
This deletes every backup in the backup directory.
### Environment Variable
`MIRRO_BACKUPS_LIFE` controls the default number of days to keep when using `mirro --prune-backups`.
Its default value is **30** if not set otherwise.
```
@@ -128,31 +140,79 @@ 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
```
### Shows current directory's history
Shows which files in the current directory have _**edit history**_ recorded by mirro.
For each file, it prints how many revisions exist and when the latest one was saved.
```
mirro --status
Files with history in /foo/bar:
baz.conf (3 revisions, latest: 2025-01-12 14:03 UTC)
```
## 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 mirro
```
### 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
git clone https://github.com/guardutils/mirro.git
cd mirro/
poetry install
```
## TAB completion
Add this to your `.bashrc`
```
eval "$(register-python-argcomplete mirro)"
```
And then
```
source ~/.bashrc
```
## How to run the tests
- Clone this repository

16
poetry.lock generated
View File

@@ -1,5 +1,19 @@
# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand.
[[package]]
name = "argcomplete"
version = "3.6.3"
description = "Bash tab completion for argparse"
optional = false
python-versions = ">=3.8"
files = [
{file = "argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce"},
{file = "argcomplete-3.6.3.tar.gz", hash = "sha256:62e8ed4fd6a45864acc8235409461b72c9a28ee785a2011cc5eb78318786c89c"},
]
[package.extras]
test = ["coverage", "mypy", "pexpect", "ruff", "wheel"]
[[package]]
name = "cfgv"
version = "3.4.0"
@@ -488,4 +502,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess
[metadata]
lock-version = "2.0"
python-versions = ">=3.10,<4.0"
content-hash = "98acd9fd57ec90c98a407b83122fd9c8ed432383e095a47d44e201bf187d3107"
content-hash = "c7b7c37f18023c55d23bd61dad81cc93600a14a17fbed43958afaac369a52f91"

View File

@@ -1,16 +1,17 @@
[tool.poetry]
name = "mirro"
version = "0.3.0"
version = "0.5.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"
readme = "README.md"
homepage = "https://github.com/mdaleo404/mirro"
repository = "https://github.com/mdaleo404/mirro"
homepage = "https://github.com/guardutils/mirro"
repository = "https://github.com/guardutils/mirro"
packages = [{include = "mirro", from = "src"}]
[tool.poetry.dependencies]
python = ">=3.10,<4.0"
argcomplete = ">=2"
[tool.poetry.scripts]
mirro = "mirro.main:main"

View File

@@ -1,9 +1,11 @@
import importlib.metadata
import argparse
import argcomplete
import tempfile
import subprocess
import os
import textwrap
import difflib
from pathlib import Path
import time
@@ -49,6 +51,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 +114,93 @@ 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",
)
parser.add_argument(
"--status",
action="store_true",
help="Show which files in the current directory have 'revisions'",
)
argcomplete.autocomplete(parser)
# 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 its 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
@@ -147,6 +258,52 @@ def main():
return
if args.status:
backup_dir = Path(args.backup_dir).expanduser().resolve()
cwd = Path.cwd()
if not backup_dir.exists():
print(f"No mirro backups found in {cwd}.")
return 0
# Build map: filename -> list of backups
backup_map = {}
for b in backup_dir.iterdir():
name = b.name
if ".orig." not in name:
continue
filename, _, _ = name.partition(".orig.")
backup_map.setdefault(filename, []).append(b)
# Find files in current dir that have backups
entries = []
for file in cwd.iterdir():
if file.is_file() and file.name in backup_map:
backups = backup_map[file.name]
backups_sorted = sorted(
backups, key=lambda x: x.stat().st_mtime, reverse=True
)
latest = backups_sorted[0]
latest_mtime = time.strftime(
"%Y-%m-%d %H:%M:%S UTC",
time.gmtime(latest.stat().st_mtime),
)
entries.append((file.name, len(backups), latest_mtime))
# Nothing found?
if not entries:
print(f"No mirro backups found in {cwd}.")
return 0
# Otherwise print nice report
print(f"Files with history in {cwd}:")
for name, count, latest in entries:
print(f" {name:16} ({count} revision(s), latest: {latest})")
return 0
if args.restore_last:
backup_dir = Path(args.backup_dir).expanduser().resolve()
target = Path(args.restore_last).expanduser().resolve()
@@ -163,7 +320,7 @@ def main():
]
if not backups:
print(f"No backups found for {target}")
print(f"No history found for {target}")
return 1
# newest backup
@@ -171,26 +328,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}")

View File

@@ -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,124 @@ 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
# ============================================================
# --status
# ============================================================
def test_status_no_backups(tmp_path, monkeypatch, capsys):
monkeypatch.chdir(tmp_path)
backup_dir = tmp_path / "bk" # no backups inside
with patch(
"sys.argv", ["mirro", "--status", "--backup-dir", str(backup_dir)]
):
result = mirro.main()
out = capsys.readouterr().out
assert result == 0
assert f"No mirro backups found in {tmp_path}" in out
def test_status_backups_found(tmp_path, monkeypatch, capsys):
monkeypatch.chdir(tmp_path)
cwd = tmp_path
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")
# 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))
with patch(
"sys.argv", ["mirro", "--status", "--backup-dir", str(backup_dir)]
):
result = mirro.main()
out = capsys.readouterr().out
assert result == 0
assert f"Backed-up files 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