Compare commits
30 Commits
remove_dev
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
74db589391
|
|||
| cc764403e3 | |||
|
e66b5d95e9
|
|||
| b23ca5573e | |||
|
b930b4239e
|
|||
|
|
4f681851ac | ||
|
e48adddd4c
|
|||
|
d2fff45db7
|
|||
|
|
b2af78e643 | ||
|
ea7c1384a0
|
|||
|
|
e88a248463 | ||
| 755b69ce99 | |||
|
|
9fa7fc1c52 | ||
|
|
d982509acb | ||
| e66bdc9a8f | |||
| 598a71dcc8 | |||
| 385d721155 | |||
| 1cb0bca865 | |||
|
|
6dbadc476a | ||
| 5f9d8a81d4 | |||
| 60ff38e207 | |||
|
|
19f285db9c | ||
| 8cf2a5f1ac | |||
|
|
7cb2c3adb2 | ||
| a0d6acfa8a | |||
| 79473eb05a | |||
| fde29fe90d | |||
| a068c0a5bd | |||
| 27b9039ddd | |||
|
|
e347b12d94 |
29
.gitea/workflows/lint-and-security.yml
Normal file
29
.gitea/workflows/lint-and-security.yml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
name: Lint & Security
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
precommit-and-security:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.13"
|
||||||
|
|
||||||
|
- name: Install pre-commit
|
||||||
|
run: pip install pre-commit
|
||||||
|
|
||||||
|
- name: Run pre-commit hooks
|
||||||
|
run: pre-commit run --all-files --color always
|
||||||
|
|
||||||
|
- name: Install pip-audit
|
||||||
|
run: pip install pip-audit
|
||||||
|
|
||||||
|
- name: Run pip-audit
|
||||||
|
run: pip-audit
|
||||||
@@ -17,3 +17,5 @@ repos:
|
|||||||
hooks:
|
hooks:
|
||||||
- id: trailing-whitespace
|
- id: trailing-whitespace
|
||||||
- id: end-of-file-fixer
|
- id: end-of-file-fixer
|
||||||
|
- id: check-yaml
|
||||||
|
- id: check-toml
|
||||||
|
|||||||
158
README.md
158
README.md
@@ -1,3 +1,7 @@
|
|||||||
|
[](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
|
# mirro
|
||||||
|
|
||||||
**mirro** is a tiny safety-first editing wrapper for text files.
|
**mirro** is a tiny safety-first editing wrapper for text files.
|
||||||
@@ -76,33 +80,177 @@ so under `sudo`:
|
|||||||
|
|
||||||
Backups are named like:
|
Backups are named like:
|
||||||
```
|
```
|
||||||
filename.ext.orig.20251110T174400.bak
|
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
|
||||||
|
```
|
||||||
|
This:
|
||||||
|
1. finds the newest backup matching the filename,
|
||||||
|
|
||||||
|
2. strips the mirro header from it,
|
||||||
|
|
||||||
|
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.
|
||||||
|
```
|
||||||
|
export MIRRO_BACKUPS_LIFE=7
|
||||||
|
```
|
||||||
|
Backups older than 7 days will be removed.
|
||||||
|
|
||||||
|
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
|
## 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/guardutils/debian stable 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/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
|
||||||
|
|
||||||
**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
|
||||||
|
|||||||
103
poetry.lock
generated
103
poetry.lock
generated
@@ -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"
|
||||||
@@ -123,6 +137,9 @@ files = [
|
|||||||
{file = "coverage-7.11.3.tar.gz", hash = "sha256:0f59387f5e6edbbffec2281affb71cdc85e0776c1745150a3ab9b6c1d016106b"},
|
{file = "coverage-7.11.3.tar.gz", hash = "sha256:0f59387f5e6edbbffec2281affb71cdc85e0776c1745150a3ab9b6c1d016106b"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""}
|
||||||
|
|
||||||
[package.extras]
|
[package.extras]
|
||||||
toml = ["tomli"]
|
toml = ["tomli"]
|
||||||
|
|
||||||
@@ -137,6 +154,23 @@ files = [
|
|||||||
{file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"},
|
{file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "exceptiongroup"
|
||||||
|
version = "1.3.0"
|
||||||
|
description = "Backport of PEP 654 (exception groups)"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.7"
|
||||||
|
files = [
|
||||||
|
{file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"},
|
||||||
|
{file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""}
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
test = ["pytest (>=6)"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "filelock"
|
name = "filelock"
|
||||||
version = "3.20.0"
|
version = "3.20.0"
|
||||||
@@ -271,10 +305,12 @@ files = [
|
|||||||
|
|
||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""}
|
colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""}
|
||||||
|
exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""}
|
||||||
iniconfig = ">=1.0.1"
|
iniconfig = ">=1.0.1"
|
||||||
packaging = ">=22"
|
packaging = ">=22"
|
||||||
pluggy = ">=1.5,<2"
|
pluggy = ">=1.5,<2"
|
||||||
pygments = ">=2.7.2"
|
pygments = ">=2.7.2"
|
||||||
|
tomli = {version = ">=1", markers = "python_version < \"3.11\""}
|
||||||
|
|
||||||
[package.extras]
|
[package.extras]
|
||||||
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"]
|
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"]
|
||||||
@@ -380,6 +416,68 @@ files = [
|
|||||||
{file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"},
|
{file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tomli"
|
||||||
|
version = "2.3.0"
|
||||||
|
description = "A lil' TOML parser"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.8"
|
||||||
|
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"},
|
||||||
|
{file = "tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf"},
|
||||||
|
{file = "tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441"},
|
||||||
|
{file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845"},
|
||||||
|
{file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c"},
|
||||||
|
{file = "tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456"},
|
||||||
|
{file = "tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be"},
|
||||||
|
{file = "tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac"},
|
||||||
|
{file = "tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22"},
|
||||||
|
{file = "tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f"},
|
||||||
|
{file = "tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52"},
|
||||||
|
{file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8"},
|
||||||
|
{file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6"},
|
||||||
|
{file = "tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876"},
|
||||||
|
{file = "tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878"},
|
||||||
|
{file = "tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b"},
|
||||||
|
{file = "tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae"},
|
||||||
|
{file = "tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b"},
|
||||||
|
{file = "tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf"},
|
||||||
|
{file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f"},
|
||||||
|
{file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05"},
|
||||||
|
{file = "tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606"},
|
||||||
|
{file = "tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999"},
|
||||||
|
{file = "tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e"},
|
||||||
|
{file = "tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3"},
|
||||||
|
{file = "tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc"},
|
||||||
|
{file = "tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0"},
|
||||||
|
{file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879"},
|
||||||
|
{file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005"},
|
||||||
|
{file = "tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463"},
|
||||||
|
{file = "tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8"},
|
||||||
|
{file = "tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77"},
|
||||||
|
{file = "tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf"},
|
||||||
|
{file = "tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530"},
|
||||||
|
{file = "tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b"},
|
||||||
|
{file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67"},
|
||||||
|
{file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f"},
|
||||||
|
{file = "tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0"},
|
||||||
|
{file = "tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba"},
|
||||||
|
{file = "tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b"},
|
||||||
|
{file = "tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-extensions"
|
||||||
|
version = "4.15.0"
|
||||||
|
description = "Backported and Experimental Type Hints for Python 3.9+"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.9"
|
||||||
|
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"},
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "virtualenv"
|
name = "virtualenv"
|
||||||
version = "20.35.4"
|
version = "20.35.4"
|
||||||
@@ -395,6 +493,7 @@ files = [
|
|||||||
distlib = ">=0.3.7,<1"
|
distlib = ">=0.3.7,<1"
|
||||||
filelock = ">=3.12.2,<4"
|
filelock = ">=3.12.2,<4"
|
||||||
platformdirs = ">=3.9.1,<5"
|
platformdirs = ">=3.9.1,<5"
|
||||||
|
typing-extensions = {version = ">=4.13.2", markers = "python_version < \"3.11\""}
|
||||||
|
|
||||||
[package.extras]
|
[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)"]
|
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)"]
|
||||||
@@ -402,5 +501,5 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess
|
|||||||
|
|
||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "2.0"
|
lock-version = "2.0"
|
||||||
python-versions = "^3.13"
|
python-versions = ">=3.10,<4.0"
|
||||||
content-hash = "7f853bdda7a4b19de579a9c54d756d25b9d75a79736c3f1439b0d37d41ac25ac"
|
content-hash = "c7b7c37f18023c55d23bd61dad81cc93600a14a17fbed43958afaac369a52f91"
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "mirro"
|
name = "mirro"
|
||||||
version = "0.2.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.13"
|
python = ">=3.10,<4.0"
|
||||||
|
argcomplete = ">=2"
|
||||||
|
|
||||||
[tool.poetry.scripts]
|
[tool.poetry.scripts]
|
||||||
mirro = "mirro.main:main"
|
mirro = "mirro.main:main"
|
||||||
|
|
||||||
[tool.poetry.group.dev.dependencies]
|
[tool.poetry.dev-dependencies]
|
||||||
pytest = "^9.0.1"
|
pytest = "^9.0.1"
|
||||||
pytest-cov = "^7.0.0"
|
pytest-cov = "^7.0.0"
|
||||||
pre-commit = "^3.8"
|
pre-commit = "^3.8"
|
||||||
|
|
||||||
[tool.black]
|
[tool.black]
|
||||||
line-length = 79
|
line-length = 79
|
||||||
target-version = ["py313"]
|
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry-core"]
|
requires = ["poetry-core"]
|
||||||
|
|||||||
@@ -1,8 +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 difflib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import time
|
import time
|
||||||
|
|
||||||
@@ -31,7 +34,7 @@ def backup_original(
|
|||||||
timestamp = time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime())
|
timestamp = time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime())
|
||||||
shortstamp = time.strftime("%Y%m%dT%H%M%S", time.gmtime())
|
shortstamp = time.strftime("%Y%m%dT%H%M%S", time.gmtime())
|
||||||
|
|
||||||
backup_name = f"{original_path.name}.orig.{shortstamp}.bak"
|
backup_name = f"{original_path.name}.orig.{shortstamp}"
|
||||||
backup_path = backup_dir / backup_name
|
backup_path = backup_dir / backup_name
|
||||||
|
|
||||||
header = (
|
header = (
|
||||||
@@ -48,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."
|
||||||
@@ -66,9 +94,318 @@ def main():
|
|||||||
version=f"mirro {get_version()}",
|
version=f"mirro {get_version()}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--list",
|
||||||
|
action="store_true",
|
||||||
|
help="List all backups in the backup directory and exit",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--restore-last",
|
||||||
|
metavar="FILE",
|
||||||
|
type=str,
|
||||||
|
help="Restore the last backup of the given file and exit",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--prune-backups",
|
||||||
|
nargs="?",
|
||||||
|
const="default",
|
||||||
|
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 it’s not absolute or ~, treat it as a filename in the backup dir
|
||||||
|
if os.path.isabs(backup_arg) or backup_arg.startswith("~"):
|
||||||
|
backup_path = Path(backup_arg).expanduser().resolve()
|
||||||
|
else:
|
||||||
|
backup_dir = Path(args.backup_dir).expanduser().resolve()
|
||||||
|
backup_path = backup_dir / backup_arg
|
||||||
|
|
||||||
|
if not file_path.exists():
|
||||||
|
print(f"File not found: {file_path}")
|
||||||
|
return 1
|
||||||
|
if not backup_path.exists():
|
||||||
|
print(f"Backup not found: {backup_path}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Enforce same base filename while diffing
|
||||||
|
target_name = file_path.name
|
||||||
|
backup_name = backup_path.name
|
||||||
|
|
||||||
|
if not backup_name.startswith(target_name + ".orig."):
|
||||||
|
print(
|
||||||
|
f"Error: Backup '{backup_name}' does not match the file being diffed.\n"
|
||||||
|
f"Expected backup file starting with: {target_name}.orig."
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
original = file_path.read_text(
|
||||||
|
encoding="utf-8", errors="replace"
|
||||||
|
).splitlines()
|
||||||
|
backup_raw = backup_path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
|
||||||
|
backup_stripped = strip_mirro_header(backup_raw)
|
||||||
|
backup = backup_stripped.splitlines()
|
||||||
|
|
||||||
|
# Generate a clean diff (no trailing line noise)
|
||||||
|
diff = difflib.unified_diff(
|
||||||
|
backup,
|
||||||
|
original,
|
||||||
|
fromfile=f"a/{file_path.name}",
|
||||||
|
tofile=f"b/{file_path.name}",
|
||||||
|
lineterm="",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Colors
|
||||||
|
RED = "\033[31m"
|
||||||
|
GREEN = "\033[32m"
|
||||||
|
CYAN = "\033[36m"
|
||||||
|
RESET = "\033[0m"
|
||||||
|
|
||||||
|
for line in diff:
|
||||||
|
if (
|
||||||
|
line.startswith("---")
|
||||||
|
or line.startswith("+++")
|
||||||
|
or line.startswith("@@")
|
||||||
|
):
|
||||||
|
print(f"{CYAN}{line}{RESET}")
|
||||||
|
elif line.startswith("+"):
|
||||||
|
print(f"{GREEN}{line}{RESET}")
|
||||||
|
elif line.startswith("-"):
|
||||||
|
print(f"{RED}{line}{RESET}")
|
||||||
|
else:
|
||||||
|
print(line)
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.list:
|
||||||
|
import pwd, grp
|
||||||
|
|
||||||
|
backup_dir = Path(args.backup_dir).expanduser().resolve()
|
||||||
|
if not backup_dir.exists():
|
||||||
|
print("No backups found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
backups = sorted(
|
||||||
|
backup_dir.iterdir(), key=os.path.getmtime, reverse=True
|
||||||
|
)
|
||||||
|
if not backups:
|
||||||
|
print("No backups found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
def perms(mode):
|
||||||
|
is_file = "-"
|
||||||
|
perms = ""
|
||||||
|
flags = [
|
||||||
|
(mode & 0o400, "r"),
|
||||||
|
(mode & 0o200, "w"),
|
||||||
|
(mode & 0o100, "x"),
|
||||||
|
(mode & 0o040, "r"),
|
||||||
|
(mode & 0o020, "w"),
|
||||||
|
(mode & 0o010, "x"),
|
||||||
|
(mode & 0o004, "r"),
|
||||||
|
(mode & 0o002, "w"),
|
||||||
|
(mode & 0o001, "x"),
|
||||||
|
]
|
||||||
|
for bit, char in flags:
|
||||||
|
perms += char if bit else "-"
|
||||||
|
return is_file + perms
|
||||||
|
|
||||||
|
for b in backups:
|
||||||
|
stat = b.stat()
|
||||||
|
mode = perms(stat.st_mode)
|
||||||
|
|
||||||
|
try:
|
||||||
|
owner = pwd.getpwuid(stat.st_uid).pw_name
|
||||||
|
except KeyError:
|
||||||
|
owner = str(stat.st_uid)
|
||||||
|
|
||||||
|
try:
|
||||||
|
group = grp.getgrgid(stat.st_gid).gr_name
|
||||||
|
except KeyError:
|
||||||
|
group = str(stat.st_gid)
|
||||||
|
|
||||||
|
owner_group = f"{owner} {group}"
|
||||||
|
|
||||||
|
mtime = time.strftime(
|
||||||
|
"%Y-%m-%d %H:%M:%S", time.gmtime(stat.st_mtime)
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"{mode:11} {owner_group:20} {mtime} {b.name}")
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
if not backup_dir.exists():
|
||||||
|
print("No backup directory found.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# backup filenames look like: <name>.orig.<timestamp>
|
||||||
|
prefix = f"{target.name}.orig."
|
||||||
|
|
||||||
|
backups = [
|
||||||
|
b for b in backup_dir.iterdir() if b.name.startswith(prefix)
|
||||||
|
]
|
||||||
|
|
||||||
|
if not backups:
|
||||||
|
print(f"No history found for {target}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# newest backup
|
||||||
|
last = max(backups, key=os.path.getmtime)
|
||||||
|
|
||||||
|
# read and strip header
|
||||||
|
raw = last.read_text(encoding="utf-8", errors="replace")
|
||||||
|
restored_text = strip_mirro_header(raw)
|
||||||
|
target.write_text(restored_text, encoding="utf-8")
|
||||||
|
|
||||||
|
print(f"Restored {target} from backup {last.name}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.prune_backups is not None:
|
||||||
|
mode = args.prune_backups
|
||||||
|
|
||||||
|
# ALL mode
|
||||||
|
if mode == "all":
|
||||||
|
prune_days = None
|
||||||
|
|
||||||
|
# default
|
||||||
|
elif mode == "default":
|
||||||
|
raw_env = os.environ.get("MIRRO_BACKUPS_LIFE", "30")
|
||||||
|
try:
|
||||||
|
prune_days = int(raw_env)
|
||||||
|
if prune_days < 1:
|
||||||
|
raise ValueError
|
||||||
|
except ValueError:
|
||||||
|
print(
|
||||||
|
f"Invalid MIRRO_BACKUPS_LIFE value: {raw_env}. "
|
||||||
|
"It must be an integer >= 1. Falling back to 30."
|
||||||
|
)
|
||||||
|
prune_days = 30
|
||||||
|
|
||||||
|
# numeric mode e.g. --prune-backups=7
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
prune_days = int(mode)
|
||||||
|
if prune_days < 1:
|
||||||
|
raise ValueError
|
||||||
|
except ValueError:
|
||||||
|
msg = f"""
|
||||||
|
Invalid value for --prune-backups: {mode}
|
||||||
|
|
||||||
|
--prune-backups use MIRRO_BACKUPS_LIFE (default: 30 days)
|
||||||
|
--prune-backups=N expire backups older than N days (N >= 1)
|
||||||
|
--prune-backups=all remove ALL backups
|
||||||
|
"""
|
||||||
|
print(textwrap.dedent(msg))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
backup_dir = Path(args.backup_dir).expanduser().resolve()
|
||||||
|
|
||||||
|
if not backup_dir.exists():
|
||||||
|
print("No backup directory found.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# prune EVERYTHING
|
||||||
|
if prune_days is None:
|
||||||
|
removed = []
|
||||||
|
for b in backup_dir.iterdir():
|
||||||
|
if b.is_file():
|
||||||
|
removed.append(b)
|
||||||
|
b.unlink()
|
||||||
|
print(f"Removed ALL backups ({len(removed)} file(s)).")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# prune by age
|
||||||
|
cutoff = time.time() - (prune_days * 86400)
|
||||||
|
removed = []
|
||||||
|
|
||||||
|
for b in backup_dir.iterdir():
|
||||||
|
if b.is_file() and b.stat().st_mtime < cutoff:
|
||||||
|
removed.append(b)
|
||||||
|
b.unlink()
|
||||||
|
|
||||||
|
if removed:
|
||||||
|
print(
|
||||||
|
f"Removed {len(removed)} backup(s) older than {prune_days} days."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(f"No backups older than {prune_days} days.")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
# Flexible positional parsing
|
# Flexible positional parsing
|
||||||
if not positional:
|
if not positional:
|
||||||
parser.error("the following arguments are required: file")
|
parser.error("the following arguments are required: file")
|
||||||
|
|||||||
@@ -48,17 +48,49 @@ 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
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
def test_backup_original(tmp_path, monkeypatch):
|
def test_backup_original(tmp_path, monkeypatch):
|
||||||
original_path = tmp_path / "test.txt"
|
original_path = tmp_path / "a.txt"
|
||||||
original_content = "ABC"
|
original_content = "ABC"
|
||||||
backup_dir = tmp_path / "backups"
|
backup_dir = tmp_path / "backups"
|
||||||
|
|
||||||
# Freeze timestamps
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
time,
|
time,
|
||||||
"gmtime",
|
"gmtime",
|
||||||
@@ -78,14 +110,14 @@ def test_backup_original(tmp_path, monkeypatch):
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert backup_path.exists()
|
assert backup_path.exists()
|
||||||
text = backup_path.read_text(encoding="utf-8")
|
text = backup_path.read_text()
|
||||||
assert "mirro backup" in text
|
assert "mirro backup" in text
|
||||||
assert "Original file:" in text
|
assert "Original file" in text
|
||||||
assert original_content in text
|
assert "ABC" in text
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Helper to run main()
|
# Helper to simulate main()
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
@@ -100,11 +132,8 @@ def simulate_main(
|
|||||||
file_exists=True,
|
file_exists=True,
|
||||||
override_access=None,
|
override_access=None,
|
||||||
):
|
):
|
||||||
"""Utility to simulate mirro.main()"""
|
|
||||||
|
|
||||||
monkeypatch.setenv("EDITOR", editor)
|
monkeypatch.setenv("EDITOR", editor)
|
||||||
|
|
||||||
# Fake editor
|
|
||||||
def fake_call(cmd):
|
def fake_call(cmd):
|
||||||
temp = Path(cmd[-1])
|
temp = Path(cmd[-1])
|
||||||
if edited_content is None:
|
if edited_content is None:
|
||||||
@@ -115,13 +144,11 @@ def simulate_main(
|
|||||||
|
|
||||||
monkeypatch.setattr(subprocess, "call", fake_call)
|
monkeypatch.setattr(subprocess, "call", fake_call)
|
||||||
|
|
||||||
# Access override if provided
|
|
||||||
if override_access:
|
if override_access:
|
||||||
monkeypatch.setattr(os, "access", override_access)
|
monkeypatch.setattr(os, "access", override_access)
|
||||||
else:
|
else:
|
||||||
monkeypatch.setattr(os, "access", lambda p, m: True)
|
monkeypatch.setattr(os, "access", lambda p, m: True)
|
||||||
|
|
||||||
# Set up file as needed
|
|
||||||
target = Path(args[-1]).expanduser().resolve()
|
target = Path(args[-1]).expanduser().resolve()
|
||||||
if file_exists:
|
if file_exists:
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -135,7 +162,7 @@ def simulate_main(
|
|||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# main: missing file argument
|
# main: missing positional file
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
@@ -143,24 +170,23 @@ def test_main_missing_argument(capsys):
|
|||||||
with patch("sys.argv", ["mirro"]):
|
with patch("sys.argv", ["mirro"]):
|
||||||
with pytest.raises(SystemExit):
|
with pytest.raises(SystemExit):
|
||||||
mirro.main()
|
mirro.main()
|
||||||
|
|
||||||
assert (
|
assert (
|
||||||
"the following arguments are required: file" in capsys.readouterr().err
|
"the following arguments are required: file" in capsys.readouterr().err
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# main: unchanged file (line 137)
|
# main: unchanged file
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
def test_main_existing_unchanged(tmp_path, monkeypatch, capsys):
|
def test_main_existing_unchanged(tmp_path, monkeypatch, capsys):
|
||||||
target = tmp_path / "file.txt"
|
target = tmp_path / "file.txt"
|
||||||
target.write_text("hello\n", encoding="utf-8")
|
target.write_text("hello\n")
|
||||||
|
|
||||||
def fake_call(cmd):
|
def fake_call(cmd):
|
||||||
temp = Path(cmd[-1])
|
temp = Path(cmd[-1])
|
||||||
temp.write_text("hello\n", encoding="utf-8")
|
temp.write_text("hello\n")
|
||||||
|
|
||||||
monkeypatch.setenv("EDITOR", "nano")
|
monkeypatch.setenv("EDITOR", "nano")
|
||||||
monkeypatch.setattr(subprocess, "call", fake_call)
|
monkeypatch.setattr(subprocess, "call", fake_call)
|
||||||
@@ -169,8 +195,7 @@ def test_main_existing_unchanged(tmp_path, monkeypatch, capsys):
|
|||||||
with patch("sys.argv", ["mirro", str(target)]):
|
with patch("sys.argv", ["mirro", str(target)]):
|
||||||
mirro.main()
|
mirro.main()
|
||||||
|
|
||||||
out = capsys.readouterr().out
|
assert "file hasn't changed" in capsys.readouterr().out
|
||||||
assert "file hasn't changed" in out
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -179,7 +204,7 @@ def test_main_existing_unchanged(tmp_path, monkeypatch, capsys):
|
|||||||
|
|
||||||
|
|
||||||
def test_main_existing_changed(tmp_path, monkeypatch, capsys):
|
def test_main_existing_changed(tmp_path, monkeypatch, capsys):
|
||||||
target = tmp_path / "file2.txt"
|
target = tmp_path / "f2.txt"
|
||||||
|
|
||||||
result, out = simulate_main(
|
result, out = simulate_main(
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
@@ -191,7 +216,7 @@ def test_main_existing_changed(tmp_path, monkeypatch, capsys):
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert "file changed; original backed up at" in out
|
assert "file changed; original backed up at" in out
|
||||||
assert target.read_text(encoding="utf-8") == "new\n"
|
assert target.read_text() == "new\n"
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -233,68 +258,57 @@ def test_main_new_file_changed(tmp_path, monkeypatch, capsys):
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert "file changed; original backed up at" in out
|
assert "file changed; original backed up at" in out
|
||||||
assert new.read_text(encoding="utf-8") == "XYZ\n"
|
assert new.read_text() == "XYZ\n"
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# main: permission denied for existing file (line 78)
|
# Permission denied branches
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
def test_main_permission_denied_existing(tmp_path, monkeypatch, capsys):
|
def test_main_permission_denied_existing(tmp_path, monkeypatch, capsys):
|
||||||
target = tmp_path / "blocked.txt"
|
tgt = tmp_path / "blocked.txt"
|
||||||
target.write_text("hello", encoding="utf-8")
|
tgt.write_text("hi")
|
||||||
|
|
||||||
monkeypatch.setenv("EDITOR", "nano")
|
monkeypatch.setenv("EDITOR", "nano")
|
||||||
monkeypatch.setattr(os, "access", lambda p, m: False)
|
monkeypatch.setattr(os, "access", lambda p, m: False)
|
||||||
|
|
||||||
with patch("sys.argv", ["mirro", str(target)]):
|
with patch("sys.argv", ["mirro", str(tgt)]):
|
||||||
result = mirro.main()
|
result = mirro.main()
|
||||||
|
|
||||||
out = capsys.readouterr().out
|
|
||||||
assert "Need elevated privileges to open" in out
|
|
||||||
assert result == 1
|
assert result == 1
|
||||||
|
assert "Need elevated privileges to open" in capsys.readouterr().out
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# main: permission denied creating file (line 84)
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
|
|
||||||
def test_main_permission_denied_create(tmp_path, monkeypatch, capsys):
|
def test_main_permission_denied_create(tmp_path, monkeypatch, capsys):
|
||||||
newfile = tmp_path / "subdir" / "nofile.txt"
|
new = tmp_path / "sub/xx.txt"
|
||||||
parent = newfile.parent
|
new.parent.mkdir(parents=True)
|
||||||
parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# Directory is not writable
|
|
||||||
def fake_access(path, mode):
|
def fake_access(path, mode):
|
||||||
if path == parent:
|
return False if path == new.parent else True
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
monkeypatch.setattr(os, "access", fake_access)
|
monkeypatch.setattr(os, "access", fake_access)
|
||||||
monkeypatch.setenv("EDITOR", "nano")
|
monkeypatch.setenv("EDITOR", "nano")
|
||||||
|
|
||||||
with patch("sys.argv", ["mirro", str(newfile)]):
|
with patch("sys.argv", ["mirro", str(new)]):
|
||||||
result = mirro.main()
|
result = mirro.main()
|
||||||
|
|
||||||
out = capsys.readouterr().out
|
|
||||||
assert "Need elevated privileges to create" in out
|
|
||||||
assert result == 1
|
assert result == 1
|
||||||
|
assert "Need elevated privileges to create" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# main: non-nano editor (ordering branch)
|
# Editor ordering: non-nano branch
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
def test_main_editor_non_nano(tmp_path, monkeypatch, capsys):
|
def test_main_editor_non_nano(tmp_path, monkeypatch, capsys):
|
||||||
target = tmp_path / "vim.txt"
|
target = tmp_path / "vim.txt"
|
||||||
target.write_text("old\n", encoding="utf-8")
|
target.write_text("old\n")
|
||||||
|
|
||||||
def fake_call(cmd):
|
def fake_call(cmd):
|
||||||
temp = Path(cmd[1]) # in non-nano mode
|
temp = Path(cmd[1])
|
||||||
temp.write_text("edited\n", encoding="utf-8")
|
temp.write_text("edited\n")
|
||||||
|
|
||||||
monkeypatch.setenv("EDITOR", "vim")
|
monkeypatch.setenv("EDITOR", "vim")
|
||||||
monkeypatch.setattr(subprocess, "call", fake_call)
|
monkeypatch.setattr(subprocess, "call", fake_call)
|
||||||
@@ -303,4 +317,313 @@ def test_main_editor_non_nano(tmp_path, monkeypatch, capsys):
|
|||||||
with patch("sys.argv", ["mirro", str(target)]):
|
with patch("sys.argv", ["mirro", str(target)]):
|
||||||
mirro.main()
|
mirro.main()
|
||||||
|
|
||||||
assert target.read_text(encoding="utf-8") == "edited\n"
|
assert target.read_text() == "edited\n"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# --list
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_list_no_dir(tmp_path, capsys):
|
||||||
|
with patch(
|
||||||
|
"sys.argv", ["mirro", "--list", "--backup-dir", str(tmp_path / "none")]
|
||||||
|
):
|
||||||
|
mirro.main()
|
||||||
|
assert "No backups found." in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_list_entries(tmp_path, capsys):
|
||||||
|
d = tmp_path / "bk"
|
||||||
|
d.mkdir()
|
||||||
|
(d / "a.txt.orig.1").write_text("x")
|
||||||
|
(d / "b.txt.orig.2").write_text("y")
|
||||||
|
|
||||||
|
with patch("sys.argv", ["mirro", "--list", "--backup-dir", str(d)]):
|
||||||
|
mirro.main()
|
||||||
|
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "a.txt.orig.1" in out
|
||||||
|
assert "b.txt.orig.2" in out
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# --restore-last
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_last_no_dir(tmp_path, capsys):
|
||||||
|
d = tmp_path / "none"
|
||||||
|
target = tmp_path / "x.txt"
|
||||||
|
with patch(
|
||||||
|
"sys.argv",
|
||||||
|
["mirro", "--restore-last", str(target), "--backup-dir", str(d)],
|
||||||
|
):
|
||||||
|
result = mirro.main()
|
||||||
|
|
||||||
|
assert result == 1
|
||||||
|
assert "No backup directory found." in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_last_no_backups(tmp_path, capsys):
|
||||||
|
d = tmp_path / "bk"
|
||||||
|
d.mkdir()
|
||||||
|
target = tmp_path / "t.txt"
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"sys.argv",
|
||||||
|
["mirro", "--restore-last", str(target), "--backup-dir", str(d)],
|
||||||
|
):
|
||||||
|
result = mirro.main()
|
||||||
|
|
||||||
|
assert result == 1
|
||||||
|
assert "No backups found" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_last_success(tmp_path, capsys):
|
||||||
|
d = tmp_path / "bk"
|
||||||
|
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(mirro_header + "old1")
|
||||||
|
b2.write_text(mirro_header + "old2")
|
||||||
|
|
||||||
|
# ensure newest
|
||||||
|
os.utime(b2, (time.time(), time.time()))
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"sys.argv",
|
||||||
|
["mirro", "--restore-last", str(target), "--backup-dir", str(d)],
|
||||||
|
):
|
||||||
|
mirro.main()
|
||||||
|
|
||||||
|
assert target.read_text() == "old2"
|
||||||
|
assert "Restored" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# --prune-backups
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
def test_prune_all(tmp_path, capsys):
|
||||||
|
d = tmp_path / "bk"
|
||||||
|
d.mkdir()
|
||||||
|
(d / "a").write_text("x")
|
||||||
|
(d / "b").write_text("y")
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"sys.argv", ["mirro", "--prune-backups=all", "--backup-dir", str(d)]
|
||||||
|
):
|
||||||
|
mirro.main()
|
||||||
|
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "Removed ALL backups" in out
|
||||||
|
assert not any(d.iterdir())
|
||||||
|
|
||||||
|
|
||||||
|
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(
|
||||||
|
"# ---------------------------------------------------\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
|
||||||
|
|||||||
Reference in New Issue
Block a user