Compare commits
31 Commits
0.4.0
...
d27bc37653
| Author | SHA1 | Date | |
|---|---|---|---|
| d27bc37653 | |||
|
47567e40e9
|
|||
|
bc94ac14bf
|
|||
|
909c4eb6f8
|
|||
|
95a989b770
|
|||
|
5ee910f3ad
|
|||
|
6c166c53c8
|
|||
|
f15738006e
|
|||
|
7a680eaab2
|
|||
|
12d6f5fd2f
|
|||
| 07d31d6f83 | |||
|
65d2cd7fee
|
|||
|
46e83e3e48
|
|||
| 3e1b2e5488 | |||
|
9adbb74602
|
|||
|
537397ac36
|
|||
|
0dc9b827f9
|
|||
|
bf0dfac0c4
|
|||
|
74db589391
|
|||
| cc764403e3 | |||
|
e66b5d95e9
|
|||
| b23ca5573e | |||
|
b930b4239e
|
|||
| 4f681851ac | |||
|
e48adddd4c
|
|||
|
d2fff45db7
|
|||
| b2af78e643 | |||
|
ea7c1384a0
|
|||
| e88a248463 | |||
| 755b69ce99 | |||
| 9fa7fc1c52 |
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
[](https://git.sysmd.uk/guardutils/mirro/src/branch/main/LICENCE)
|
||||
[](https://git.sysmd.uk/guardutils/mirro/releases)
|
||||
[](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.
|
||||
|
||||
@@ -82,12 +90,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
|
||||
```
|
||||
@@ -98,25 +108,36 @@ 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.
|
||||
|
||||
```
|
||||
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.
|
||||
```
|
||||
@@ -129,28 +150,80 @@ 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
|
||||
### 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 dnf install resrm
|
||||
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
|
||||
```
|
||||
|
||||
### From PyPI
|
||||
@@ -172,11 +245,23 @@ pip install mirro
|
||||
|
||||
### From this repository
|
||||
```
|
||||
git clone https://github.com/mdaleo404/mirro.git
|
||||
git clone https://git.sysmd.uk/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
|
||||
|
||||
Generated
+51
-12
@@ -1,4 +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 2.3.3 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"
|
||||
groups = ["main"]
|
||||
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"
|
||||
@@ -6,6 +21,7 @@ version = "3.4.0"
|
||||
description = "Validate configuration and produce human readable error messages."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"},
|
||||
{file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"},
|
||||
@@ -17,6 +33,8 @@ version = "0.4.6"
|
||||
description = "Cross-platform colored terminal text."
|
||||
optional = false
|
||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
|
||||
groups = ["dev"]
|
||||
markers = "sys_platform == \"win32\""
|
||||
files = [
|
||||
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
|
||||
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
|
||||
@@ -28,6 +46,7 @@ version = "7.11.3"
|
||||
description = "Code coverage measurement for Python"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "coverage-7.11.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0c986537abca9b064510f3fd104ba33e98d3036608c7f2f5537f869bc10e1ee5"},
|
||||
{file = "coverage-7.11.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:28c5251b3ab1d23e66f1130ca0c419747edfbcb4690de19467cd616861507af7"},
|
||||
@@ -127,7 +146,7 @@ files = [
|
||||
tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""}
|
||||
|
||||
[package.extras]
|
||||
toml = ["tomli"]
|
||||
toml = ["tomli ; python_full_version <= \"3.11.0a6\""]
|
||||
|
||||
[[package]]
|
||||
name = "distlib"
|
||||
@@ -135,6 +154,7 @@ version = "0.4.0"
|
||||
description = "Distribution utilities"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16"},
|
||||
{file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"},
|
||||
@@ -146,6 +166,8 @@ version = "1.3.0"
|
||||
description = "Backport of PEP 654 (exception groups)"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["dev"]
|
||||
markers = "python_version == \"3.10\""
|
||||
files = [
|
||||
{file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"},
|
||||
{file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"},
|
||||
@@ -159,13 +181,14 @@ 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"
|
||||
groups = ["dev"]
|
||||
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]]
|
||||
@@ -174,6 +197,7 @@ version = "2.6.15"
|
||||
description = "File identification library for Python"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757"},
|
||||
{file = "identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf"},
|
||||
@@ -188,6 +212,7 @@ version = "2.3.0"
|
||||
description = "brain-dead simple config-ini parsing"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"},
|
||||
{file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"},
|
||||
@@ -199,6 +224,7 @@ version = "1.9.1"
|
||||
description = "Node.js virtual environment builder"
|
||||
optional = false
|
||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"},
|
||||
{file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"},
|
||||
@@ -210,6 +236,7 @@ version = "25.0"
|
||||
description = "Core utilities for Python packages"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"},
|
||||
{file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"},
|
||||
@@ -221,6 +248,7 @@ version = "4.5.0"
|
||||
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3"},
|
||||
{file = "platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312"},
|
||||
@@ -237,6 +265,7 @@ version = "1.6.0"
|
||||
description = "plugin and hook calling mechanisms for python"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
|
||||
{file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
|
||||
@@ -252,6 +281,7 @@ version = "3.8.0"
|
||||
description = "A framework for managing and maintaining multi-language pre-commit hooks."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f"},
|
||||
{file = "pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af"},
|
||||
@@ -270,6 +300,7 @@ version = "2.19.2"
|
||||
description = "Pygments is a syntax highlighting package written in Python."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"},
|
||||
{file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"},
|
||||
@@ -284,6 +315,7 @@ version = "9.0.1"
|
||||
description = "pytest: simple powerful testing with Python"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad"},
|
||||
{file = "pytest-9.0.1.tar.gz", hash = "sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8"},
|
||||
@@ -307,6 +339,7 @@ version = "7.0.0"
|
||||
description = "Pytest plugin for measuring coverage."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861"},
|
||||
{file = "pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1"},
|
||||
@@ -326,6 +359,7 @@ version = "6.0.3"
|
||||
description = "YAML parser and emitter for Python"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"},
|
||||
{file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"},
|
||||
@@ -408,6 +442,8 @@ version = "2.3.0"
|
||||
description = "A lil' TOML parser"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
markers = "python_full_version <= \"3.11.0a6\""
|
||||
files = [
|
||||
{file = "tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45"},
|
||||
{file = "tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba"},
|
||||
@@ -459,6 +495,8 @@ version = "4.15.0"
|
||||
description = "Backported and Experimental Type Hints for Python 3.9+"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["dev"]
|
||||
markers = "python_version == \"3.10\""
|
||||
files = [
|
||||
{file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
|
||||
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
|
||||
@@ -466,26 +504,27 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "virtualenv"
|
||||
version = "20.35.4"
|
||||
version = "20.36.1"
|
||||
description = "Virtual Python Environment builder"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
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\""}
|
||||
|
||||
[package.extras]
|
||||
docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"]
|
||||
test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"]
|
||||
test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"GraalVM\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""]
|
||||
|
||||
[metadata]
|
||||
lock-version = "2.0"
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.10,<4.0"
|
||||
content-hash = "98acd9fd57ec90c98a407b83122fd9c8ed432383e095a47d44e201bf187d3107"
|
||||
content-hash = "b90604ce6169c72e35550fd278fab34b383e08f72e6db019f3a8ae611c2eae0a"
|
||||
|
||||
+5
-4
@@ -1,21 +1,22 @@
|
||||
[tool.poetry]
|
||||
name = "mirro"
|
||||
version = "0.4.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/mdaleo404/mirro"
|
||||
repository = "https://github.com/mdaleo404/mirro"
|
||||
homepage = "https://git.sysmd.uk/guardutils/mirro"
|
||||
repository = "https://git.sysmd.uk/guardutils/mirro"
|
||||
packages = [{include = "mirro", from = "src"}]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = ">=3.10,<4.0"
|
||||
argcomplete = ">=2"
|
||||
|
||||
[tool.poetry.scripts]
|
||||
mirro = "mirro.main:main"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
pytest = "^9.0.1"
|
||||
pytest-cov = "^7.0.0"
|
||||
pre-commit = "^3.8"
|
||||
|
||||
+115
-1
@@ -1,5 +1,6 @@
|
||||
import importlib.metadata
|
||||
import argparse
|
||||
import argcomplete
|
||||
import tempfile
|
||||
import subprocess
|
||||
import os
|
||||
@@ -75,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."
|
||||
@@ -106,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="?",
|
||||
@@ -120,6 +141,14 @@ def main():
|
||||
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()
|
||||
|
||||
@@ -249,6 +278,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()
|
||||
@@ -265,7 +340,7 @@ def main():
|
||||
]
|
||||
|
||||
if not backups:
|
||||
print(f"No backups found for {target}")
|
||||
print(f"No history found for {target}")
|
||||
return 1
|
||||
|
||||
# newest backup
|
||||
@@ -279,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
|
||||
|
||||
|
||||
+110
-151
@@ -8,7 +8,6 @@ import pytest
|
||||
|
||||
import mirro.main as mirro
|
||||
|
||||
|
||||
# ============================================================
|
||||
# get_version
|
||||
# ============================================================
|
||||
@@ -72,7 +71,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 +296,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 +352,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 +379,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,153 +392,135 @@ 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",
|
||||
"--restore",
|
||||
bad_backup.name,
|
||||
"--backup-dir",
|
||||
str(backup_dir),
|
||||
],
|
||||
):
|
||||
mirro.main()
|
||||
result = 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
|
||||
assert result == 1
|
||||
assert (
|
||||
"Could not determine original file location" in capsys.readouterr().out
|
||||
)
|
||||
|
||||
|
||||
def test_diff_wrong_backup_name_rejected(tmp_path, capsys):
|
||||
d = tmp_path / "bk"
|
||||
d.mkdir()
|
||||
# ============================================================
|
||||
# --status
|
||||
# ============================================================
|
||||
|
||||
file = tmp_path / "foo.txt"
|
||||
file.write_text("hello\n")
|
||||
|
||||
bad = d / "bar.txt.orig.20250101T010203"
|
||||
bad.write_text("stuff\n")
|
||||
def test_status_no_backups(tmp_path, monkeypatch, capsys):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
backup_dir = tmp_path / "bk"
|
||||
with patch(
|
||||
"sys.argv",
|
||||
["mirro", "--diff", str(file), bad.name, "--backup-dir", str(d)],
|
||||
"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
|
||||
|
||||
assert result == 1
|
||||
assert "does not match the file being diffed" in out
|
||||
assert "foo.txt.orig." 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()
|
||||
|
||||
(tmp_path / "a.txt").write_text("data1")
|
||||
(tmp_path / "b.txt").write_text("data2")
|
||||
|
||||
(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)]
|
||||
):
|
||||
result = mirro.main()
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert result == 0
|
||||
assert f"Files with history in {cwd}:" in out
|
||||
assert "a.txt" in out
|
||||
assert "b.txt" in out
|
||||
|
||||
Reference in New Issue
Block a user