13 Commits
v0.3.2 ... main

Author SHA1 Message Date
58c682d4d3 Merge pull request 'Improve trash output' (#24) from improve_trash_list into main
Reviewed-on: #24
2025-12-13 18:09:46 +00:00
45d9f5f6c8 Update README, version bump
All checks were successful
Lint & Security / precommit-and-security (pull_request) Successful in 1m2s
2025-12-13 18:06:59 +00:00
659a76f5c9 Make --empty delete dangling files in trash folder not associated with metadata file, edit completer function's name to be reusable 2025-12-13 18:04:55 +00:00
250077c592 Add --inspect flag 2025-12-13 17:34:35 +00:00
631843b3c5 Fix installation instructions 2025-12-09 16:15:13 +00:00
9c653e44a4 Fix release badge link 2025-12-09 15:15:19 +00:00
cdd3ba0cbd Merge pull request 'Update README and pyproject.toml' (#23) from update_resrm_20251209 into main
Reviewed-on: #23
2025-12-09 15:13:46 +00:00
eee00bb6ee Edit badges, update installation instructions, swap github.com entries to git.sysmd.uk
All checks were successful
Lint & Security / precommit-and-security (pull_request) Successful in 48s
2025-12-09 15:11:54 +00:00
f9586bbd0e Merge pull request 'Rename .github folder to .gitea. Use pre-commit directly instead of action' (#22) from rename_github_folder into main
Reviewed-on: #22
2025-12-09 13:23:09 +00:00
51a7001bf2 Rename .github folder to .gitea. Use pre-commit directly instead of action
All checks were successful
Lint & Security / precommit-and-security (pull_request) Successful in 47s
2025-12-09 13:19:46 +00:00
ccf383ebfb Remove .coverage and add that to the .gitignore 2025-12-03 11:58:46 +00:00
Marco D'Aleo
6670c79d47 Merge pull request #21 from guardutils/args_list_fix
Fix list flag to use the long name
2025-12-03 11:57:06 +00:00
3285fbaef4 Fix list flag to use the long name 2025-12-03 11:55:15 +00:00
6 changed files with 175 additions and 41 deletions

View File

@@ -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

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
__pycache__ __pycache__
.pytest_cache .pytest_cache
dist dist
.coverage

View File

@@ -1,8 +1,6 @@
[![License](https://img.shields.io/github/license/guardutils/resrm?style=flat)](LICENCE) [![Licence](https://img.shields.io/badge/GPL--3.0-orange?label=Licence)](https://git.sysmd.uk/guardutils/resrm/src/branch/main/LICENCE)
[![Language](https://img.shields.io/github/languages/top/guardutils/resrm.svg)](https://github.com/guardutils/resrm/) [![Gitea Release](https://img.shields.io/gitea/v/release/guardutils/resrm?gitea_url=https%3A%2F%2Fgit.sysmd.uk%2F&style=flat&color=orange&logo=gitea)](https://git.sysmd.uk/guardutils/resrm/releases)
[![GitHub Release](https://img.shields.io/github/v/release/guardutils/resrm?display_name=release&logo=github)](https://github.com/guardutils/resrm/releases) [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-blue?logo=pre-commit&style=flat)](https://git.sysmd.uk/guardutils/resrm/src/branch/main/.pre-commit-config.yaml)
[![PyPI - Version](https://img.shields.io/pypi/v/resrm?logo=pypi)](https://pypi.org/project/resrm/#history)
[![PyPI downloads](https://img.shields.io/pypi/dm/resrm.svg)](https://pypi.org/project/resrm/)
# resrm # resrm
@@ -22,34 +20,63 @@ It moves files to a per-user _trash_ instead of permanently deleting them, while
> Note: if you need immediate deletion, use the `--skip-trash` flag. > Note: if you need immediate deletion, use the `--skip-trash` flag.
---
## Configuration
To control how long trashed files are kept, add this line to your shell configuration (e.g. `~/.bashrc`):
```bash
export RESRM_TRASH_LIFE=10
```
---
## 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/guardutils/debian stable main" | sudo tee /etc/apt/sources.list.d/guardutils.list
```
#### 3) Update and install
``` ```
sudo add-apt-repository ppa:mdaleo/resrm
sudo apt update sudo apt update
sudo apt install resrm sudo apt install resrm
``` ```
**Fedora 41, 42, 43** ### Fedora/RHEL
#### 1) Import the GPG key
``` ```
sudo dnf copr enable mdaleo/resrm sudo rpm --import https://repo.sysmd.uk/guardutils/guardutils.gpg
```
#### 2) Add the repository configuration
```
sudo tee /etc/yum.repos.d/guardutils.repo > /dev/null << 'EOF'
[guardutils]
name=GuardUtils Repository
baseurl=https://repo.sysmd.uk/guardutils/rpm/$basearch
enabled=1
gpgcheck=1
repo_gpgcheck=1
gpgkey=https://repo.sysmd.uk/guardutils/guardutils.gpg
EOF
```
#### 4) Update and install
```
sudo dnf upgrade --refresh
sudo dnf install resrm sudo dnf install resrm
``` ```
@@ -74,7 +101,7 @@ pip install resrm
### From this repository ### From this repository
```bash ```bash
git clone https://github.com/guardutils/resrm.git git clone https://git.sysmd.uk/guardutils/resrm.git
cd resrm/ cd resrm/
poetry install poetry install
``` ```
@@ -103,16 +130,28 @@ resrm -l
# Restore a file by ID or basename # Restore a file by ID or basename
resrm --restore <id|name> resrm --restore <id|name>
# Show full details of trashed item
resrm --inspect <id|name>
# Empty the trash permanently # Empty the trash permanently
resrm --empty resrm --empty
``` ```
## Trash Location ## Trash Location
Normal users: `~/.local/share/resrm/files` Normal users: `~/.local/share/resrm/files`
Root user: `/root/.local/share/resrm/files` Root user: `/root/.local/share/resrm/files`
## Configuration
To control how long trashed files are kept, add this line to your shell configuration (e.g. `~/.bashrc`):
```bash
export RESRM_TRASH_LIFE=10
```
### TAB completion ### TAB completion
Add this to your `.bashrc` Add this to your `.bashrc`
``` ```

View File

@@ -1,12 +1,12 @@
[tool.poetry] [tool.poetry]
name = "resrm" name = "resrm"
version = "0.3.2" version = "0.4.0"
description = "drop-in replacement for rm with undo/restore built-in." description = "drop-in replacement for rm with undo/restore built-in."
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/guardutils/resrm" homepage = "https://git.sysmd.uk/guardutils/resrm"
repository = "https://github.com/guardutils/resrm" repository = "https://git.sysmd.uk/guardutils/resrm"
packages = [{include = "resrm", from = "src"}] packages = [{include = "resrm", from = "src"}]
[tool.poetry.dependencies] [tool.poetry.dependencies]

View File

@@ -8,8 +8,9 @@ Basic usage:
resrm -f file # ignore nonexistent, no prompt resrm -f file # ignore nonexistent, no prompt
resrm -i file # interactive prompt before removal resrm -i file # interactive prompt before removal
resrm --skip-trash file # permanent delete (bypass trash) resrm --skip-trash file # permanent delete (bypass trash)
resrm -l # list trash entries (neat table) resrm -l|--list # list trash entries (neat table)
resrm --restore <id|name> # restore by short-id (8 chars) or exact basename resrm --restore <id|name> # restore by short-id (8 chars) or exact basename
resrm --inspect <id|name> # output full detail list of trashed item
resrm --empty # empty trash entries (permanent) resrm --empty # empty trash entries (permanent)
""" """
@@ -268,20 +269,23 @@ def restore(identifier: str):
def empty_trash(): def empty_trash():
"""Permanently remove all trashed files and clear metadata.""" """Permanently remove all trashed files and clear metadata."""
# Remove everything inside the trash directory
count = 0 count = 0
for entry in list(meta): for item in TRASH_DIR.iterdir():
f = TRASH_DIR / entry["id"]
try: try:
if f.exists(): if item.is_dir():
if f.is_dir(): shutil.rmtree(item, ignore_errors=True)
shutil.rmtree(f, ignore_errors=True) else:
else: item.unlink(missing_ok=True)
f.unlink(missing_ok=True)
meta.remove(entry)
count += 1 count += 1
except Exception as e: except Exception as e:
print(f"Failed to remove {f}: {e}") print(f"Failed to remove {item}: {e}")
# Clear metadata
meta.clear()
save_meta(meta) save_meta(meta)
print(f"Trash emptied ({count} entries removed).") print(f"Trash emptied ({count} entries removed).")
@@ -374,6 +378,79 @@ def move_to_trash(
print(f"Removed '{path}' -> trash id {short_id(uid)}") print(f"Removed '{path}' -> trash id {short_id(uid)}")
def inspect_entry(identifier: str):
"""Show full information about trash entries matching the identifier."""
candidates = find_candidates(identifier)
if not candidates:
print(f"No match found for '{identifier}'")
return
for entry in candidates:
# Validate entry structure
if not isinstance(entry, dict):
print(f"Invalid metadata entry (not a dict): {entry!r}")
print()
continue
entry_id = entry.get("id")
orig_path = entry.get("orig_path", "?")
timestamp = entry.get("timestamp", "?")
if not entry_id:
print(f"Invalid metadata entry (missing id): {entry}")
continue
trash_path = TRASH_DIR / entry_id
print(f"ID: {short_id(entry_id)}")
print(f"Original: {orig_path}")
print(f"Deleted at: {human_time(timestamp)}")
print(f"Stored at: {trash_path}")
try:
st = trash_path.lstat() # preserves symlink info
import stat, pwd, grp
# Type detection
if stat.S_ISDIR(st.st_mode):
ftype = "directory"
elif stat.S_ISLNK(st.st_mode):
try:
target = os.readlink(trash_path)
ftype = f"symlink → {target}"
except Exception:
ftype = "symlink"
else:
ftype = "file"
# Permissions
perms = stat.filemode(st.st_mode)
# Ownership
try:
user = pwd.getpwuid(st.st_uid).pw_name
except Exception:
user = st.st_uid
try:
group = grp.getgrgid(st.st_gid).gr_name
except Exception:
group = st.st_gid
owner = f"{user}:{group}"
# Size (bytes for file, recursive for directories)
size = st.st_size
print(f"Type: {ftype}")
print(f"Size: {size} bytes")
print(f"Permissions: {perms}")
print(f"Ownership: {owner}")
except Exception as e:
print(f"Unknown stats for {e}")
def main(argv: Optional[List[str]] = None): def main(argv: Optional[List[str]] = None):
if argv is None: if argv is None:
argv = sys.argv[1:] argv = sys.argv[1:]
@@ -386,6 +463,15 @@ def main(argv: Optional[List[str]] = None):
parser.add_argument( parser.add_argument(
"--skip-trash", action="store_true", help="permanent delete" "--skip-trash", action="store_true", help="permanent delete"
) )
inspect_arg = parser.add_argument(
"--inspect",
"-I",
nargs="+",
metavar="item",
help="show full metadata and original path for this trash entry",
)
restore_arg = parser.add_argument( restore_arg = parser.add_argument(
"--restore", "--restore",
nargs="+", nargs="+",
@@ -393,8 +479,8 @@ def main(argv: Optional[List[str]] = None):
help="restore by id or basename", help="restore by id or basename",
) )
# restore completer # completer
def restore_completer(prefix, parsed_args, **kwargs): def id_name_completer(prefix, parsed_args, **kwargs):
return [ return [
short_id(m["id"]) short_id(m["id"])
for m in meta for m in meta
@@ -405,7 +491,8 @@ def main(argv: Optional[List[str]] = None):
if Path(m["orig_path"]).name.startswith(prefix) if Path(m["orig_path"]).name.startswith(prefix)
] ]
restore_arg.completer = restore_completer restore_arg.completer = id_name_completer
inspect_arg.completer = id_name_completer
parser.add_argument("-l", "--list", action="store_true", help="list trash") parser.add_argument("-l", "--list", action="store_true", help="list trash")
parser.add_argument( parser.add_argument(
"--empty", action="store_true", help="empty the trash permanently" "--empty", action="store_true", help="empty the trash permanently"
@@ -424,15 +511,22 @@ def main(argv: Optional[List[str]] = None):
print(__doc__) print(__doc__)
return return
if not args.paths and not (args.l or args.empty or args.restore): if not args.paths and not (
args.list or args.empty or args.restore or args.inspect
):
print("resrm: missing operand") print("resrm: missing operand")
print("Try 'resrm --help' for more information.") print("Try 'resrm --help' for more information.")
return return
if args.l: if args.list:
list_trash() list_trash()
return return
if args.inspect:
for item in args.inspect:
inspect_entry(item)
return
if args.empty: if args.empty:
empty_trash() empty_trash()
return return