20 Commits

Author SHA1 Message Date
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
b23ca5573e Merge pull request 'Rename .github folder to .gitea. Use pre-commit directly instead of action' (#13) from rename_github_folder into main
Reviewed-on: #13
2025-12-09 13:18:47 +00:00
b930b4239e Rename .github folder to .gitea. Use pre-commit directly instead of action
All checks were successful
Lint & Security / precommit-and-security (pull_request) Successful in 49s
2025-12-09 13:12:36 +00:00
Marco D'Aleo
4f681851ac Merge pull request #12 from guardutils/update_mirro_20251204
Add --status flag, TAB completion with argcomplete, update README, update tests
2025-12-04 15:12:37 +00:00
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
7 changed files with 453 additions and 36 deletions

View File

@@ -1,7 +1,7 @@
name: CI name: Lint & Security
on: on:
push: pull_request:
jobs: jobs:
precommit-and-security: precommit-and-security:
@@ -20,7 +20,7 @@ jobs:
run: pip install pre-commit run: pip install pre-commit
- name: Run pre-commit hooks - name: Run pre-commit hooks
uses: pre-commit/action@v3.0.1 run: pre-commit run --all-files --color always
- name: Install pip-audit - name: Install pip-audit
run: pip install pip-audit run: pip install pip-audit

108
README.md
View File

@@ -1,3 +1,7 @@
[![Licence](https://img.shields.io/badge/GPL--3.0-orange?label=Licence)](https://git.sysmd.uk/guardutils/mirro/src/branch/main/LICENCE)
[![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)
[![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)
# mirro # mirro
**mirro** is a tiny safety-first editing wrapper for text files. **mirro** is a tiny safety-first editing wrapper for text files.
@@ -82,12 +86,14 @@ filename.ext.orig.20251110T174400
## Functionalities ## Functionalities
### List all backup files stored in your backup directory. ### List all backup files stored in your backup directory.
``` ```
mirro --list mirro --list
``` ```
Output includes permissions, owner/group, timestamps, and backup filenames. Output includes permissions, owner/group, timestamps, and backup filenames.
### Restore the most recent backup for a given file. ### Restore the most recent backup for a given file.
``` ```
mirro --restore-last ~/.config/myapp/config.ini mirro --restore-last ~/.config/myapp/config.ini
``` ```
@@ -99,24 +105,28 @@ This:
3. and overwrites the target file with its original contents. 3. and overwrites the target file with its original contents.
### Remove old backup files. ### Remove old backup files.
``` ```
mirro --prune-backups mirro --prune-backups
``` ```
This removes backups older than the number of days set in `MIRRO_BACKUPS_LIFE`. This removes backups older than the number of days set in `MIRRO_BACKUPS_LIFE`.
### Remove backups older than _N_ days ### Remove backups older than _N_ days
``` ```
mirro --prune-backups=14 mirro --prune-backups=14
``` ```
This keeps the last 14 days of backups and removes everything older. This keeps the last 14 days of backups and removes everything older.
### Remove all backups ### Remove all backups
``` ```
mirro --prune-backups=all mirro --prune-backups=all
``` ```
This deletes every backup in the backup directory. This deletes every backup in the backup directory.
### Environment Variable ### Environment Variable
`MIRRO_BACKUPS_LIFE` controls the default number of days to keep when using `mirro --prune-backups`. `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. Its default value is **30** if not set otherwise.
``` ```
@@ -128,31 +138,121 @@ 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
```
### 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 ## Installation
### From GuardUtils package repo
This is the preferred method of installation.
### 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 debian main" | sudo tee /etc/apt/sources.list.d/guardutils.list
```
#### 3) Update and install
```
sudo apt update
sudo apt install mirro
```
### Fedora/RHEL
#### 1) Import the GPG key
```
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/rpm/$basearch
enabled = 1
gpgcheck = 1
gpgkey = https://repo.sysmd.uk/guardutils/guardutils.gpg
repo_gpgcheck = 1
EOF
```
#### 4) Update and install
```
sudo dnf upgrade --refresh
sudo dnf install mirro
```
### From PyPI
**NOTE:** To use `mirro` with `sudo`, the path to `mirro` must be in the `$PATH` seen by `root`.\ **NOTE:** To use `mirro` with `sudo`, the path to `mirro` must be in the `$PATH` seen by `root`.\
Either: 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: * 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 ``` bash
Defaults secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/home/user/.local/bin" 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 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/ cd mirro/
poetry install poetry install
``` ```
## TAB completion
Add this to your `.bashrc`
```
eval "$(register-python-argcomplete mirro)"
```
And then
```
source ~/.bashrc
```
## How to run the tests ## How to run the tests
- Clone this repository - 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. # 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]] [[package]]
name = "cfgv" name = "cfgv"
version = "3.4.0" version = "3.4.0"
@@ -488,4 +502,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = ">=3.10,<4.0" python-versions = ">=3.10,<4.0"
content-hash = "98acd9fd57ec90c98a407b83122fd9c8ed432383e095a47d44e201bf187d3107" content-hash = "c7b7c37f18023c55d23bd61dad81cc93600a14a17fbed43958afaac369a52f91"

View File

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

View File

@@ -1,9 +1,11 @@
import importlib.metadata import importlib.metadata
import argparse import argparse
import argcomplete
import tempfile 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 +51,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 +114,93 @@ 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",
)
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. # 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 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: if args.list:
import pwd, grp import pwd, grp
@@ -147,6 +258,52 @@ def main():
return 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: if args.restore_last:
backup_dir = Path(args.backup_dir).expanduser().resolve() backup_dir = Path(args.backup_dir).expanduser().resolve()
target = Path(args.restore_last).expanduser().resolve() target = Path(args.restore_last).expanduser().resolve()
@@ -163,7 +320,7 @@ def main():
] ]
if not backups: if not backups:
print(f"No backups found for {target}") print(f"No history found for {target}")
return 1 return 1
# newest backup # newest backup
@@ -171,26 +328,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}")

View File

@@ -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,124 @@ 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
# ============================================================
# --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