Compare commits
12 Commits
args_list_
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 58c682d4d3 | |||
|
45d9f5f6c8
|
|||
|
659a76f5c9
|
|||
|
250077c592
|
|||
|
631843b3c5
|
|||
|
9c653e44a4
|
|||
| cdd3ba0cbd | |||
|
eee00bb6ee
|
|||
| f9586bbd0e | |||
|
51a7001bf2
|
|||
|
ccf383ebfb
|
|||
|
|
6670c79d47 |
@@ -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
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
|||||||
__pycache__
|
__pycache__
|
||||||
.pytest_cache
|
.pytest_cache
|
||||||
dist
|
dist
|
||||||
|
.coverage
|
||||||
|
|||||||
83
README.md
83
README.md
@@ -1,8 +1,6 @@
|
|||||||
[](LICENCE)
|
[](https://git.sysmd.uk/guardutils/resrm/src/branch/main/LICENCE)
|
||||||
[](https://github.com/guardutils/resrm/)
|
[](https://git.sysmd.uk/guardutils/resrm/releases)
|
||||||
[](https://github.com/guardutils/resrm/releases)
|
[](https://git.sysmd.uk/guardutils/resrm/src/branch/main/.pre-commit-config.yaml)
|
||||||
[](https://pypi.org/project/resrm/#history)
|
|
||||||
[](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`
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "resrm"
|
name = "resrm"
|
||||||
version = "0.3.3"
|
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]
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ Basic usage:
|
|||||||
resrm --skip-trash file # permanent delete (bypass trash)
|
resrm --skip-trash file # permanent delete (bypass trash)
|
||||||
resrm -l|--list # 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,7 +511,9 @@ def main(argv: Optional[List[str]] = None):
|
|||||||
print(__doc__)
|
print(__doc__)
|
||||||
return
|
return
|
||||||
|
|
||||||
if not args.paths and not (args.list 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
|
||||||
@@ -433,6 +522,11 @@ def main(argv: Optional[List[str]] = None):
|
|||||||
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
|
||||||
|
|||||||
Reference in New Issue
Block a user