Compare commits
13 Commits
update_mir
...
cc764403e3
| Author | SHA1 | Date | |
|---|---|---|---|
| cc764403e3 | |||
|
e66b5d95e9
|
|||
| b23ca5573e | |||
|
b930b4239e
|
|||
|
|
4f681851ac | ||
|
e48adddd4c
|
|||
|
d2fff45db7
|
|||
|
|
b2af78e643 | ||
|
ea7c1384a0
|
|||
|
|
e88a248463 | ||
| 755b69ce99 | |||
|
|
9fa7fc1c52 | ||
|
|
d982509acb |
@@ -20,7 +20,7 @@ jobs:
|
|||||||
run: pip install pre-commit
|
run: pip install pre-commit
|
||||||
|
|
||||||
- name: Run pre-commit hooks
|
- name: Run pre-commit hooks
|
||||||
uses: pre-commit/action@v3.0.1
|
run: pre-commit run --all-files --color always
|
||||||
|
|
||||||
- name: Install pip-audit
|
- name: Install pip-audit
|
||||||
run: pip install pip-audit
|
run: pip install pip-audit
|
||||||
90
README.md
90
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.
|
||||||
@@ -82,12 +86,14 @@ filename.ext.orig.20251110T174400
|
|||||||
## Functionalities
|
## Functionalities
|
||||||
|
|
||||||
### List all backup files stored in your backup directory.
|
### List all backup files stored in your backup directory.
|
||||||
|
|
||||||
```
|
```
|
||||||
mirro --list
|
mirro --list
|
||||||
```
|
```
|
||||||
Output includes permissions, owner/group, timestamps, and backup filenames.
|
Output includes permissions, owner/group, timestamps, and backup filenames.
|
||||||
|
|
||||||
### Restore the most recent backup for a given file.
|
### Restore the most recent backup for a given file.
|
||||||
|
|
||||||
```
|
```
|
||||||
mirro --restore-last ~/.config/myapp/config.ini
|
mirro --restore-last ~/.config/myapp/config.ini
|
||||||
```
|
```
|
||||||
@@ -99,24 +105,28 @@ This:
|
|||||||
3. and overwrites the target file with its original contents.
|
3. and overwrites the target file with its original contents.
|
||||||
|
|
||||||
### Remove old backup files.
|
### Remove old backup files.
|
||||||
|
|
||||||
```
|
```
|
||||||
mirro --prune-backups
|
mirro --prune-backups
|
||||||
```
|
```
|
||||||
This removes backups older than the number of days set in `MIRRO_BACKUPS_LIFE`.
|
This removes backups older than the number of days set in `MIRRO_BACKUPS_LIFE`.
|
||||||
|
|
||||||
### Remove backups older than _N_ days
|
### Remove backups older than _N_ days
|
||||||
|
|
||||||
```
|
```
|
||||||
mirro --prune-backups=14
|
mirro --prune-backups=14
|
||||||
```
|
```
|
||||||
This keeps the last 14 days of backups and removes everything older.
|
This keeps the last 14 days of backups and removes everything older.
|
||||||
|
|
||||||
### Remove all backups
|
### Remove all backups
|
||||||
|
|
||||||
```
|
```
|
||||||
mirro --prune-backups=all
|
mirro --prune-backups=all
|
||||||
```
|
```
|
||||||
This deletes every backup in the backup directory.
|
This deletes every backup in the backup directory.
|
||||||
|
|
||||||
### Environment Variable
|
### Environment Variable
|
||||||
|
|
||||||
`MIRRO_BACKUPS_LIFE` controls the default number of days to keep when using `mirro --prune-backups`.
|
`MIRRO_BACKUPS_LIFE` controls the default number of days to keep when using `mirro --prune-backups`.
|
||||||
Its default value is **30** if not set otherwise.
|
Its default value is **30** if not set otherwise.
|
||||||
```
|
```
|
||||||
@@ -129,28 +139,82 @@ Invalid or non-numeric values fall back to 30 days.
|
|||||||
**Note:** _a value of 0 is **invalid**_.
|
**Note:** _a value of 0 is **invalid**_.
|
||||||
|
|
||||||
### Built-in diff
|
### Built-in diff
|
||||||
|
|
||||||
This shows a _git-like_ diff of the current file version and any of that file backups.
|
This shows a _git-like_ diff of the current file version and any of that file backups.
|
||||||
```
|
```
|
||||||
mirro --diff file file.orig.20251121T163121
|
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 package manager
|
### From GuardUtils package repo
|
||||||
|
|
||||||
This is the preferred method of installation.
|
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 debian 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 update
|
||||||
sudo apt install mirro
|
sudo apt install mirro
|
||||||
```
|
```
|
||||||
|
|
||||||
**Fedora 41, 42, 43**
|
### Fedora/RHEL
|
||||||
|
|
||||||
|
#### 1) Import the GPG key
|
||||||
|
|
||||||
```
|
```
|
||||||
sudo dnf copr enable mdaleo/mirro
|
sudo rpm --import https://repo.sysmd.uk/guardutils/guardutils.gpg
|
||||||
sudo dnf install resrm
|
```
|
||||||
|
|
||||||
|
#### 2) Add the repository configuration
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo tee /etc/yum.repos.d/guardutils.repo > /dev/null << 'EOF'
|
||||||
|
[guardutils]
|
||||||
|
name = GuardUtils Repository
|
||||||
|
baseurl = https://repo.sysmd.uk/rpm/$basearch
|
||||||
|
|
||||||
|
enabled = 1
|
||||||
|
gpgcheck = 1
|
||||||
|
gpgkey = https://repo.sysmd.uk/guardutils/guardutils.gpg
|
||||||
|
|
||||||
|
repo_gpgcheck = 1
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4) Update and install
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo dnf upgrade --refresh
|
||||||
|
sudo dnf install mirro
|
||||||
```
|
```
|
||||||
|
|
||||||
### From PyPI
|
### From PyPI
|
||||||
@@ -172,11 +236,23 @@ pip install mirro
|
|||||||
|
|
||||||
### From this repository
|
### From this repository
|
||||||
```
|
```
|
||||||
git clone https://github.com/mdaleo404/mirro.git
|
git clone https://github.com/guardutils/mirro.git
|
||||||
cd mirro/
|
cd mirro/
|
||||||
poetry install
|
poetry install
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## TAB completion
|
||||||
|
|
||||||
|
Add this to your `.bashrc`
|
||||||
|
```
|
||||||
|
eval "$(register-python-argcomplete mirro)"
|
||||||
|
```
|
||||||
|
|
||||||
|
And then
|
||||||
|
```
|
||||||
|
source ~/.bashrc
|
||||||
|
```
|
||||||
|
|
||||||
## How to run the tests
|
## How to run the tests
|
||||||
|
|
||||||
- Clone this repository
|
- Clone this repository
|
||||||
|
|||||||
16
poetry.lock
generated
16
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"
|
||||||
@@ -488,4 +502,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess
|
|||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "2.0"
|
lock-version = "2.0"
|
||||||
python-versions = ">=3.10,<4.0"
|
python-versions = ">=3.10,<4.0"
|
||||||
content-hash = "98acd9fd57ec90c98a407b83122fd9c8ed432383e095a47d44e201bf187d3107"
|
content-hash = "c7b7c37f18023c55d23bd61dad81cc93600a14a17fbed43958afaac369a52f91"
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "mirro"
|
name = "mirro"
|
||||||
version = "0.4.0"
|
version = "0.5.0"
|
||||||
description = "A safe editing wrapper: edits a temp copy, compares, and saves original backup if changed."
|
description = "A safe editing wrapper: edits a temp copy, compares, and saves original backup if changed."
|
||||||
authors = ["Marco D'Aleo <marco@marcodaleo.com>"]
|
authors = ["Marco D'Aleo <marco@marcodaleo.com>"]
|
||||||
license = "GPL-3.0-or-later"
|
license = "GPL-3.0-or-later"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
homepage = "https://github.com/mdaleo404/mirro"
|
homepage = "https://git.sysmd.uk/guardutils/mirro"
|
||||||
repository = "https://github.com/mdaleo404/mirro"
|
repository = "https://git.sysmd.uk/guardutils/mirro"
|
||||||
packages = [{include = "mirro", from = "src"}]
|
packages = [{include = "mirro", from = "src"}]
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
python = ">=3.10,<4.0"
|
python = ">=3.10,<4.0"
|
||||||
|
argcomplete = ">=2"
|
||||||
|
|
||||||
[tool.poetry.scripts]
|
[tool.poetry.scripts]
|
||||||
mirro = "mirro.main:main"
|
mirro = "mirro.main:main"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import importlib.metadata
|
import importlib.metadata
|
||||||
import argparse
|
import argparse
|
||||||
|
import argcomplete
|
||||||
import tempfile
|
import tempfile
|
||||||
import subprocess
|
import subprocess
|
||||||
import os
|
import os
|
||||||
@@ -120,6 +121,14 @@ def main():
|
|||||||
help="Show a unified diff between FILE and BACKUP and exit",
|
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()
|
||||||
|
|
||||||
@@ -249,6 +258,52 @@ def main():
|
|||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if args.status:
|
||||||
|
backup_dir = Path(args.backup_dir).expanduser().resolve()
|
||||||
|
cwd = Path.cwd()
|
||||||
|
|
||||||
|
if not backup_dir.exists():
|
||||||
|
print(f"No mirro backups found in {cwd}.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Build map: filename -> list of backups
|
||||||
|
backup_map = {}
|
||||||
|
for b in backup_dir.iterdir():
|
||||||
|
name = b.name
|
||||||
|
if ".orig." not in name:
|
||||||
|
continue
|
||||||
|
filename, _, _ = name.partition(".orig.")
|
||||||
|
backup_map.setdefault(filename, []).append(b)
|
||||||
|
|
||||||
|
# Find files in current dir that have backups
|
||||||
|
entries = []
|
||||||
|
for file in cwd.iterdir():
|
||||||
|
if file.is_file() and file.name in backup_map:
|
||||||
|
backups = backup_map[file.name]
|
||||||
|
backups_sorted = sorted(
|
||||||
|
backups, key=lambda x: x.stat().st_mtime, reverse=True
|
||||||
|
)
|
||||||
|
latest = backups_sorted[0]
|
||||||
|
|
||||||
|
latest_mtime = time.strftime(
|
||||||
|
"%Y-%m-%d %H:%M:%S UTC",
|
||||||
|
time.gmtime(latest.stat().st_mtime),
|
||||||
|
)
|
||||||
|
|
||||||
|
entries.append((file.name, len(backups), latest_mtime))
|
||||||
|
|
||||||
|
# Nothing found?
|
||||||
|
if not entries:
|
||||||
|
print(f"No mirro backups found in {cwd}.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Otherwise print nice report
|
||||||
|
print(f"Files with history in {cwd}:")
|
||||||
|
for name, count, latest in entries:
|
||||||
|
print(f" {name:16} ({count} revision(s), latest: {latest})")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
if args.restore_last:
|
if args.restore_last:
|
||||||
backup_dir = Path(args.backup_dir).expanduser().resolve()
|
backup_dir = Path(args.backup_dir).expanduser().resolve()
|
||||||
target = Path(args.restore_last).expanduser().resolve()
|
target = Path(args.restore_last).expanduser().resolve()
|
||||||
@@ -265,7 +320,7 @@ def main():
|
|||||||
]
|
]
|
||||||
|
|
||||||
if not backups:
|
if not backups:
|
||||||
print(f"No backups found for {target}")
|
print(f"No history found for {target}")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
# newest backup
|
# newest backup
|
||||||
|
|||||||
@@ -565,3 +565,65 @@ def test_diff_wrong_backup_name_rejected(tmp_path, capsys):
|
|||||||
assert result == 1
|
assert result == 1
|
||||||
assert "does not match the file being diffed" in out
|
assert "does not match the file being diffed" in out
|
||||||
assert "foo.txt.orig." 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