diff --git a/README.md b/README.md index 836e4dc..26091be 100644 --- a/README.md +++ b/README.md @@ -88,12 +88,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 ``` @@ -105,24 +107,28 @@ This: 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. ``` @@ -135,11 +141,23 @@ 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 @@ -183,6 +201,18 @@ 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 diff --git a/poetry.lock b/poetry.lock index 14af928..a67bbcb 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,19 @@ # 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]] name = "cfgv" version = "3.4.0" @@ -488,4 +502,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.0" python-versions = ">=3.10,<4.0" -content-hash = "98acd9fd57ec90c98a407b83122fd9c8ed432383e095a47d44e201bf187d3107" +content-hash = "c7b7c37f18023c55d23bd61dad81cc93600a14a17fbed43958afaac369a52f91" diff --git a/pyproject.toml b/pyproject.toml index 041731d..4d80d0b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] 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." authors = ["Marco D'Aleo "] license = "GPL-3.0-or-later" @@ -11,6 +11,7 @@ packages = [{include = "mirro", from = "src"}] [tool.poetry.dependencies] python = ">=3.10,<4.0" +argcomplete = ">=2" [tool.poetry.scripts] mirro = "mirro.main:main" diff --git a/src/mirro/main.py b/src/mirro/main.py index 7c7c2e5..00878ad 100644 --- a/src/mirro/main.py +++ b/src/mirro/main.py @@ -1,5 +1,6 @@ import importlib.metadata import argparse +import argcomplete import tempfile import subprocess import os @@ -120,6 +121,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 +258,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 +320,7 @@ def main(): ] if not backups: - print(f"No backups found for {target}") + print(f"No history found for {target}") return 1 # newest backup diff --git a/tests/test_mirro.py b/tests/test_mirro.py index f202bc7..9508282 100644 --- a/tests/test_mirro.py +++ b/tests/test_mirro.py @@ -565,3 +565,65 @@ def test_diff_wrong_backup_name_rejected(tmp_path, capsys): 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