diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 42d875d..0230c1c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,6 @@ name: CI on: push: - pull_request: jobs: precommit-and-security: diff --git a/README.md b/README.md index 15e4f69..6f2b9a6 100644 --- a/README.md +++ b/README.md @@ -76,9 +76,59 @@ so under `sudo`: 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**_. + + ## Installation **NOTE:** To use `mirro` with `sudo`, the path to `mirro` must be in the `$PATH` seen by `root`.\ diff --git a/poetry.lock b/poetry.lock index f41250d..14af928 100644 --- a/poetry.lock +++ b/poetry.lock @@ -123,6 +123,9 @@ files = [ {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] toml = ["tomli"] @@ -137,6 +140,23 @@ files = [ {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]] name = "filelock" version = "3.20.0" @@ -271,10 +291,12 @@ files = [ [package.dependencies] colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} iniconfig = ">=1.0.1" packaging = ">=22" pluggy = ">=1.5,<2" pygments = ">=2.7.2" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] @@ -380,6 +402,68 @@ files = [ {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]] name = "virtualenv" version = "20.35.4" @@ -395,6 +479,7 @@ files = [ distlib = ">=0.3.7,<1" filelock = ">=3.12.2,<4" 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)"] @@ -402,5 +487,5 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.0" -python-versions = "^3.13" -content-hash = "1c2812806ab8c56e09acb3105aa4f94bcca569ed25fc4ded38865e5c9b8afaab" +python-versions = ">=3.10,<4.0" +content-hash = "98acd9fd57ec90c98a407b83122fd9c8ed432383e095a47d44e201bf187d3107" diff --git a/pyproject.toml b/pyproject.toml index 27236d7..7ebe5c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "mirro" -version = "0.2.0" +version = "0.3.0" description = "A safe editing wrapper: edits a temp copy, compares, and saves original backup if changed." authors = ["Marco D'Aleo "] license = "GPL-3.0-or-later" @@ -10,7 +10,7 @@ repository = "https://github.com/mdaleo404/mirro" packages = [{include = "mirro", from = "src"}] [tool.poetry.dependencies] -python = "^3.13" +python = ">=3.10,<4.0" [tool.poetry.scripts] mirro = "mirro.main:main" @@ -22,7 +22,6 @@ pre-commit = "^3.8" [tool.black] line-length = 79 -target-version = ["py313"] [build-system] requires = ["poetry-core"] diff --git a/src/mirro/main.py b/src/mirro/main.py index 7d7fe54..29b28fa 100644 --- a/src/mirro/main.py +++ b/src/mirro/main.py @@ -3,6 +3,7 @@ import argparse import tempfile import subprocess import os +import textwrap from pathlib import Path import time @@ -66,9 +67,207 @@ def main(): 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", + ) + # Parse only options. Leave everything else untouched. args, positional = parser.parse_known_args() + 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.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: .orig. + prefix = f"{target.name}.orig." + + backups = [ + b for b in backup_dir.iterdir() if b.name.startswith(prefix) + ] + + if not backups: + print(f"No backups 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 = [] + skipping = True + for line in raw.splitlines(keepends=True): + # header ends at first blank line after the dashed line block + if skipping: + if line.strip() == "" and restored == []: + # allow only after header + continue + if line.startswith("#") or line.strip() == "": + continue + skipping = False + restored.append(line) + + # if header wasn't present, restored = raw + if not restored: + restored_text = raw + else: + restored_text = "".join(restored) + + # write the restored file back + target.write_text(restored_text, encoding="utf-8") + + 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 if not positional: parser.error("the following arguments are required: file")