20 Commits

Author SHA1 Message Date
mdaleo404 909c4eb6f8 Exclude unfixed vulnerabilities from security workflow results
Security Scan / security-scan (push) Successful in 1m18s
2026-03-25 16:36:25 +00:00
mdaleo404 95a989b770 Switch Trivy scan to Syft and Grype 2026-03-25 16:16:12 +00:00
mdaleo404 5ee910f3ad Disable trivy scan workflow 2026-03-23 08:02:54 +00:00
mdaleo404 6c166c53c8 Update pre-commit hooks version
Trivy Scan / security-scan (push) Successful in 27s
2026-03-21 07:22:43 +00:00
mdaleo404 f15738006e Ping Trivy docker image to 0.69.3@sha256:bcc376de8d77cfe086a917230e818dc9f8528e3c852f7b1aff648949b6258d1c 2026-03-21 07:03:47 +00:00
mdaleo404 7a680eaab2 Update filelock and virtualenv
Trivy Scan / security-scan (push) Successful in 26s
2026-01-15 17:03:50 +00:00
mdaleo404 12d6f5fd2f Add trivy-scan workflow 2026-01-15 16:50:56 +00:00
mdaleo404 07d31d6f83 Merge pull request 'Make pip-audit run inside Poetry' (#16) from pip_audit_tweak into main
Reviewed-on: #16
2025-12-25 10:28:13 +00:00
mdaleo404 65d2cd7fee Make pip-audit run inside Poetry
Lint & Security / precommit-and-security (pull_request) Successful in 56s
2025-12-25 10:26:46 +00:00
mdaleo404 46e83e3e48 Add logo file, update README 2025-12-21 08:47:55 +00:00
mdaleo404 3e1b2e5488 Merge pull request 'Add restore feature' (#15) from add_restore_feature into main
Reviewed-on: #15
2025-12-15 17:25:29 +00:00
mdaleo404 9adbb74602 Update README, version bump 0.6.0
Lint & Security / precommit-and-security (pull_request) Successful in 48s
2025-12-15 17:23:08 +00:00
mdaleo404 537397ac36 Update tests with --restore flag 2025-12-15 17:19:17 +00:00
mdaleo404 0dc9b827f9 Add restore (any) feature 2025-12-15 17:18:40 +00:00
mdaleo404 bf0dfac0c4 Fix repository's URL to point ad Gitea 2025-12-14 16:41:37 +00:00
mdaleo404 74db589391 Fix installation instructions 2025-12-09 16:14:17 +00:00
mdaleo404 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
mdaleo404 e66b5d95e9 Edit badges, update installation instructions, swap github.com entries to git.sysmd.uk
Lint & Security / precommit-and-security (pull_request) Successful in 47s
2025-12-09 15:20:06 +00:00
mdaleo404 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
mdaleo404 b930b4239e Rename .github folder to .gitea. Use pre-commit directly instead of action
Lint & Security / precommit-and-security (pull_request) Successful in 49s
2025-12-09 13:12:36 +00:00
10 changed files with 411 additions and 210 deletions
@@ -20,10 +20,17 @@ jobs:
run: pip install pre-commit
- name: Run pre-commit hooks
uses: pre-commit/action@v3.0.1
run: pre-commit run --all-files --color always
- name: Install Poetry
run: |
pip install poetry
poetry self add poetry-plugin-export
- name: Install pip-audit
run: pip install pip-audit
- name: Run pip-audit
run: pip-audit
- name: Audit dependencies (Poetry lockfile)
run: |
poetry export -f requirements.txt --without-hashes \
| pip-audit -r /dev/stdin
+188
View File
@@ -0,0 +1,188 @@
name: Security Scan
on:
schedule:
- cron: 27 8 * * *
workflow_dispatch:
jobs:
security-scan:
runs-on: running-man
env:
TARGET_DIR: .
COSIGN_VERSION: v3.0.5
SYFT_VERSION: v1.42.3
GRYPE_VERSION: v0.110.0
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Cosign (bootstrap)
run: |
set -euo pipefail
FILE="cosign-linux-amd64"
curl -fLO https://github.com/sigstore/cosign/releases/download/${COSIGN_VERSION}/${FILE}
chmod +x ${FILE}
mv ${FILE} /usr/local/bin/cosign
cosign version
- name: Install Syft (verified)
run: |
set -euo pipefail
VERSION_NO_V="${SYFT_VERSION#v}"
FILE="syft_${VERSION_NO_V}_linux_amd64.tar.gz"
BASE_URL="https://github.com/anchore/syft/releases/download/${SYFT_VERSION}"
curl -fLO ${BASE_URL}/${FILE}
curl -fLO ${BASE_URL}/syft_${VERSION_NO_V}_checksums.txt
curl -fLO ${BASE_URL}/syft_${VERSION_NO_V}_checksums.txt.sig
curl -fLO ${BASE_URL}/syft_${VERSION_NO_V}_checksums.txt.pem
cosign verify-blob \
--signature syft_${VERSION_NO_V}_checksums.txt.sig \
--certificate syft_${VERSION_NO_V}_checksums.txt.pem \
--certificate-identity-regexp "https://github.com/anchore/syft" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
syft_${VERSION_NO_V}_checksums.txt
CHECKSUM_LINE=$(grep " ${FILE}$" syft_${VERSION_NO_V}_checksums.txt)
if [ -z "$CHECKSUM_LINE" ]; then
echo "Missing checksum entry for ${FILE}"
exit 1
fi
echo "$CHECKSUM_LINE" | sha256sum -c -
tar -xzf ${FILE}
mv syft /usr/local/bin/
syft version
- name: Install Grype (verified)
run: |
set -euo pipefail
VERSION_NO_V="${GRYPE_VERSION#v}"
FILE="grype_${VERSION_NO_V}_linux_amd64.tar.gz"
BASE_URL="https://github.com/anchore/grype/releases/download/${GRYPE_VERSION}"
curl -fLO ${BASE_URL}/${FILE}
curl -fLO ${BASE_URL}/grype_${VERSION_NO_V}_checksums.txt
curl -fLO ${BASE_URL}/grype_${VERSION_NO_V}_checksums.txt.sig
curl -fLO ${BASE_URL}/grype_${VERSION_NO_V}_checksums.txt.pem
cosign verify-blob \
--signature grype_${VERSION_NO_V}_checksums.txt.sig \
--certificate grype_${VERSION_NO_V}_checksums.txt.pem \
--certificate-identity-regexp "https://github.com/anchore/grype" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
grype_${VERSION_NO_V}_checksums.txt
CHECKSUM_LINE=$(grep " ${FILE}$" grype_${VERSION_NO_V}_checksums.txt)
if [ -z "$CHECKSUM_LINE" ]; then
echo "Missing checksum entry for ${FILE}"
exit 1
fi
echo "$CHECKSUM_LINE" | sha256sum -c -
tar -xzf ${FILE}
mv grype /usr/local/bin/
grype version
- name: Generate SBOM
working-directory: ${{ env.TARGET_DIR }}
run: |
syft dir:. -o json > sbom.json
- name: Show SBOM contents
working-directory: ${{ env.TARGET_DIR }}
run: |
echo "Packages discovered by Syft:"
jq -r '.artifacts[] | "\(.name)@\(.version) [\(.type)]"' sbom.json | sort
- name: Run Grype scan (JSON)
id: audit
continue-on-error: true
working-directory: ${{ env.TARGET_DIR }}
run: |
grype sbom:sbom.json -o json > grype.json
echo "Vulnerabilities (fixable only):"
jq -r '
.matches[]
| select((.vulnerability.fix.versions | length) > 0)
| "\(.artifact.name)@\(.artifact.version) -> \(.vulnerability.id) [\(.vulnerability.severity)] | fixed: \(.vulnerability.fix.versions[0])"
' grype.json
# Fail only on fixable MEDIUM/HIGH/CRITICAL
jq -e '
[
.matches[]?
| select(
(
.vulnerability.severity == "Medium" or
.vulnerability.severity == "High" or
.vulnerability.severity == "Critical"
)
and
(
(.vulnerability.fix.versions | length) > 0
)
)
]
| length == 0
' grype.json
- name: Show full Grype table
working-directory: ${{ env.TARGET_DIR }}
run: |
echo "Full Grype report:"
grype sbom:sbom.json -o table
- name: Notify Node-RED on vulnerabilities
if: steps.audit.outcome == 'failure'
working-directory: ${{ env.TARGET_DIR }}
run: |
jq '
{
repo: "guardutils/mirro",
summary: (
"Total: " +
(
[
.matches[]
| select((.vulnerability.fix.versions | length) > 0)
] | length | tostring
)
),
vulnerabilities: [
.matches[]
| select((.vulnerability.fix.versions | length) > 0)
| {
library: .artifact.name,
cve: .vulnerability.id,
severity: .vulnerability.severity,
installed: .artifact.version,
fixed: (.vulnerability.fix.versions[0]),
title: .vulnerability.description,
url: .vulnerability.dataSource
}
]
}
' grype.json \
| curl -s -X POST https://nodered.sysmd.uk/vulns-alert \
-H "Content-Type: application/json" \
--data-binary @-
- name: Fail workflow if vulnerabilities found
if: steps.audit.outcome == 'failure'
run: exit 1
+3 -3
View File
@@ -1,19 +1,19 @@
repos:
- repo: https://github.com/PyCQA/bandit
rev: 1.7.9
rev: 1.9.4
hooks:
- id: bandit
files: ^src/mirro/
args: ["-lll", "-iii", "-s", "B110,B112"]
- repo: https://github.com/psf/black-pre-commit-mirror
rev: 25.11.0
rev: 26.3.1
hooks:
- id: black
language_version: python3.13
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
rev: v6.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
+60 -11
View File
@@ -1,11 +1,13 @@
[![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/)
[![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
<div align="center">
<img src="mirro.png" alt="mirro logo" width="256" />
</div>
**mirro** is a tiny safety-first editing wrapper for text files.
You edit a temporary file, **mirro** detects whether anything changed, and if it did, it saves a backup of the original before writing your changes.
@@ -106,6 +108,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.
```
@@ -160,20 +169,60 @@ Files with history in /foo/bar:
## Installation
### From package manager
### From GuardUtils package repo
This is the preferred method of installation.
**Ubuntu 22.04 and 24.04**
### 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/guardutils/debian stable main" | sudo tee /etc/apt/sources.list.d/guardutils.list
```
#### 3) Update and install
```
sudo add-apt-repository ppa:mdaleo/mirro
sudo apt update
sudo apt install mirro
```
**Fedora 41, 42, 43**
### Fedora/RHEL
#### 1) Import the GPG key
```
sudo dnf copr enable mdaleo/mirro
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/guardutils/rpm/$basearch
enabled=1
gpgcheck=1
repo_gpgcheck=1
gpgkey=https://repo.sysmd.uk/guardutils/guardutils.gpg
EOF
```
#### 4) Update and install
```
sudo dnf upgrade --refresh
sudo dnf install mirro
```
@@ -196,7 +245,7 @@ pip install mirro
### From this repository
```
git clone https://github.com/guardutils/mirro.git
git clone https://git.sysmd.uk/guardutils/mirro.git
cd mirro/
poetry install
```
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Generated
+7 -7
View File
@@ -173,13 +173,13 @@ test = ["pytest (>=6)"]
[[package]]
name = "filelock"
version = "3.20.0"
version = "3.20.3"
description = "A platform independent file lock."
optional = false
python-versions = ">=3.10"
files = [
{file = "filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2"},
{file = "filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4"},
{file = "filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1"},
{file = "filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1"},
]
[[package]]
@@ -480,18 +480,18 @@ files = [
[[package]]
name = "virtualenv"
version = "20.35.4"
version = "20.36.1"
description = "Virtual Python Environment builder"
optional = false
python-versions = ">=3.8"
files = [
{file = "virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b"},
{file = "virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c"},
{file = "virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f"},
{file = "virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba"},
]
[package.dependencies]
distlib = ">=0.3.7,<1"
filelock = ">=3.12.2,<4"
filelock = {version = ">=3.20.1,<4", markers = "python_version >= \"3.10\""}
platformdirs = ">=3.9.1,<5"
typing-extensions = {version = ">=4.13.2", markers = "python_version < \"3.11\""}
+3 -3
View File
@@ -1,12 +1,12 @@
[tool.poetry]
name = "mirro"
version = "0.5.0"
version = "0.6.1"
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/guardutils/mirro"
repository = "https://github.com/guardutils/mirro"
homepage = "https://git.sysmd.uk/guardutils/mirro"
repository = "https://git.sysmd.uk/guardutils/mirro"
packages = [{include = "mirro", from = "src"}]
[tool.poetry.dependencies]
+59
View File
@@ -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
+81 -183
View File
@@ -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