Compare commits
36 Commits
restore_ma
...
v0.3.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3aff6d8c5 | ||
| 2013e6b645 | |||
|
|
6a73270f23 | ||
| 4f1e5043fd | |||
| 877a490b57 | |||
|
|
bcad54d94e | ||
| 80fb24b7c5 | |||
| 5f8d6c03d8 | |||
|
|
be9867a007 | ||
| e76c8726a2 | |||
| 657f14d95d | |||
|
|
b6c6fcc14d | ||
| e5f029738a | |||
| 1fbe499177 | |||
| 662166afb8 | |||
|
|
7197bfce0c | ||
|
|
7ee0e0a99e | ||
| dcd1165066 | |||
|
|
45ebe97a9a | ||
| dfcffb19e4 | |||
| b221fa3534 | |||
| a24cc8f2b9 | |||
|
|
d7330933bc | ||
| ac32bae975 | |||
|
|
b181e31a0c | ||
| 4fd1243472 | |||
|
|
328eeaca7a | ||
| 19b79b26ff | |||
|
|
a67e31c65d | ||
| 9d4608bd34 | |||
|
|
27468ae0d0 | ||
| 8b1d9a81a1 | |||
| 6eb3f5a210 | |||
|
|
9a64bef661 | ||
| 5b46a3af01 | |||
|
|
c8cc694e3c |
1
.github/CODEOWNERS
vendored
Normal file
1
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
* @mdaleo404
|
||||||
48
.github/workflows/publish.yml
vendored
48
.github/workflows/publish.yml
vendored
@@ -1,48 +0,0 @@
|
|||||||
name: Publish to PyPI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- "*"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-publish:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.13"
|
|
||||||
|
|
||||||
- name: Install Poetry
|
|
||||||
uses: snok/install-poetry@v1
|
|
||||||
with:
|
|
||||||
version: 1.8.4
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: poetry install --no-root --only main
|
|
||||||
|
|
||||||
- name: Build package
|
|
||||||
run: poetry build
|
|
||||||
|
|
||||||
- name: Generate checksums
|
|
||||||
run: |
|
|
||||||
cd dist
|
|
||||||
for file in *; do
|
|
||||||
sha256sum "$file" > "$file.sha256"
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Publish to PyPI
|
|
||||||
env:
|
|
||||||
POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_TOKEN }}
|
|
||||||
run: poetry publish
|
|
||||||
|
|
||||||
- name: Upload artifacts to GitHub Release
|
|
||||||
uses: softprops/action-gh-release@v1
|
|
||||||
with:
|
|
||||||
files: |
|
|
||||||
dist/*
|
|
||||||
18
.github/workflows/release-please.yml
vendored
18
.github/workflows/release-please.yml
vendored
@@ -1,18 +0,0 @@
|
|||||||
name: Release Please
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: ["main"]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
release-please:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Release Please
|
|
||||||
uses: googleapis/release-please-action@v4
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
19
.pre-commit-config.yaml
Normal file
19
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
repos:
|
||||||
|
- repo: https://github.com/PyCQA/bandit
|
||||||
|
rev: 1.7.9
|
||||||
|
hooks:
|
||||||
|
- id: bandit
|
||||||
|
files: ^src/resrm/
|
||||||
|
args: ["-lll", "-iii", "-s", "B110,B112"]
|
||||||
|
|
||||||
|
- repo: https://github.com/psf/black-pre-commit-mirror
|
||||||
|
rev: 25.11.0
|
||||||
|
hooks:
|
||||||
|
- id: black
|
||||||
|
language_version: python3.13
|
||||||
|
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
rev: v4.4.0
|
||||||
|
hooks:
|
||||||
|
- id: trailing-whitespace
|
||||||
|
- id: end-of-file-fixer
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
".": "0.1.1"
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
# How to Contribute to `resrm`
|
|
||||||
|
|
||||||
Thanks for your interest in contributing to `resrm`! This guide walks you through the process step by step so you can send contributions with confidence.
|
|
||||||
|
|
||||||
### For external contributors
|
|
||||||
|
|
||||||
1. **Fork the repository** via GitHub: click *Fork* at the top right.
|
|
||||||
2. **Clone your fork**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/<your-username>/resrm.git
|
|
||||||
cd resrm
|
|
||||||
```
|
|
||||||
3. **Add the upstream repo** so you can sync later:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git remote add upstream https://github.com/mdaleo404/resrm.git
|
|
||||||
```
|
|
||||||
4. **Create a feature branch**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git checkout -b feature/my-new-thing
|
|
||||||
```
|
|
||||||
5. Make your changes and test them locally.
|
|
||||||
6. **Push to your fork**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git push -u origin feature/my-new-thing
|
|
||||||
```
|
|
||||||
7. Open a **Pull Request** targeting the `main` branch of the upstream repo.
|
|
||||||
|
|
||||||
### For maintainers
|
|
||||||
|
|
||||||
You may create branches directly in the main repository without forking.
|
|
||||||
|
|
||||||
## Commit Message Style Commit Message Style
|
|
||||||
|
|
||||||
To keep the automated changelog clean, follow this commit style:
|
|
||||||
|
|
||||||
**Format:**
|
|
||||||
|
|
||||||
```
|
|
||||||
type: short description
|
|
||||||
```
|
|
||||||
|
|
||||||
**Accepted types:**
|
|
||||||
|
|
||||||
* `feat:` new features
|
|
||||||
* `fix:` bug fixes
|
|
||||||
* `refactor:` internal improvements
|
|
||||||
* `perf:` performance changes
|
|
||||||
* `docs:` documentation updates
|
|
||||||
* `chore:` maintenance
|
|
||||||
* `test:` test-only changes
|
|
||||||
|
|
||||||
**Examples:**
|
|
||||||
|
|
||||||
```
|
|
||||||
feat: add --dry-run flag
|
|
||||||
fix: handle unicode paths
|
|
||||||
refactor: simplify cleanup logic
|
|
||||||
```
|
|
||||||
|
|
||||||
Don’t overthink it — the maintainer can adjust commit types during merge if needed.
|
|
||||||
|
|
||||||
## What Happens After You Submit a PR?
|
|
||||||
|
|
||||||
After reviewing your changes, your PR (hopefully) gets merged. Then:
|
|
||||||
|
|
||||||
* **Release Please** automatically creates a **release PR** summarizing changes
|
|
||||||
* The maintainer merges that release PR
|
|
||||||
* A GitHub Release is created
|
|
||||||
* The package is published to PyPI
|
|
||||||
* Wheels, source tarballs, and checksums are attached to the GitHub Release
|
|
||||||
|
|
||||||
No need to bump versions manually (unless needed).
|
|
||||||
|
|
||||||
## Code Style
|
|
||||||
|
|
||||||
* Follow standard Python formatting
|
|
||||||
* Add type hints when possible
|
|
||||||
* Keep functions small and readable
|
|
||||||
* Stick to clear naming
|
|
||||||
|
|
||||||
## Need Help, Found a Bug, Have a Request?
|
|
||||||
|
|
||||||
If you're unsure about anything:
|
|
||||||
|
|
||||||
* **Open an issue**
|
|
||||||
* Start a discussion
|
|
||||||
* Ask questions before writing lots of code
|
|
||||||
|
|
||||||
## Thank You
|
|
||||||
|
|
||||||
Your contributions — large or small — help make `resrm` better for everyone. Thanks for being part of the project!
|
|
||||||
49
README.md
49
README.md
@@ -1,24 +1,43 @@
|
|||||||
# resrm
|
# resrm
|
||||||
|
|
||||||
**resrm** is a safe, drop-in replacement for the Linux `rm` command with **undo/restore support**.
|
**resrm** is a safe, drop-in replacement for the Linux `rm` command with **undo/restore support**.
|
||||||
It moves files to a per-user _trash_ instead of permanently deleting them, while still allowing full `sudo` support for root-owned files.
|
It moves files to a per-user _trash_ instead of permanently deleting them, while still allowing full `sudo` support for root-owned files.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Move files and directories to a **trash folder** instead of permanent deletion
|
- Move files and directories to a **Trash folder** instead of permanent deletion
|
||||||
- Restore deleted files by **short ID or exact basename**
|
- Restore deleted files by **short ID or exact basename**
|
||||||
- Empty trash safely
|
- Empty trash safely
|
||||||
- Supports `-r`, `-f`, `-i`, `--perma` options
|
- Supports `-r`, `-f`, `-i`, `--skip-trash` options
|
||||||
- Works with `sudo` for root-owned files
|
- Works with `sudo` for root-owned files
|
||||||
|
- Automatically prunes Trash entries older than `$RESRM_TRASH_LIFE` days (default **7**, minimum **1**)
|
||||||
|
> Note: if you need immediate deletion, use the regular `rm` command instead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
**NOTE:** To use `resrm` with `sudo`, the path to `resrm` must be in the `$PATH` seen by `root`.\
|
**NOTE:** To use `resrm` with `sudo`, the path to `resrm` must be in the `$PATH` seen by `root`.\
|
||||||
Either install `resrm` as `root` (_preferred_), use `sudo -E resrm`, or add the `$PATH` to `/etc/sudoers` using its `Defaults secure_path` parameter.
|
Either:
|
||||||
|
|
||||||
|
* install `resrm` as `root` (_preferred_), or
|
||||||
|
* add the path to `resrm` to the `secure_path` parameter in `/etc/sudoers`. For example, where `/home/user/.local/bin` is where `resrm` is:
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
Defaults secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/home/user/.local/bin"
|
||||||
|
```
|
||||||
|
|
||||||
Install via PyPI (_preferred_):
|
Install via PyPI (_preferred_):
|
||||||
|
|
||||||
@@ -30,7 +49,7 @@ Or clone the repo and install locally:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/mdaleo404/resrm.git
|
git clone https://github.com/mdaleo404/resrm.git
|
||||||
cd resrm/resrm
|
cd resrm/
|
||||||
poetry install
|
poetry install
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -50,7 +69,7 @@ resrm -f file
|
|||||||
resrm -i file
|
resrm -i file
|
||||||
|
|
||||||
# Permanent delete (bypass trash)
|
# Permanent delete (bypass trash)
|
||||||
resrm --perma file
|
resrm --skip-trash file
|
||||||
|
|
||||||
# List trash entries
|
# List trash entries
|
||||||
resrm -l
|
resrm -l
|
||||||
@@ -66,4 +85,14 @@ resrm --empty
|
|||||||
|
|
||||||
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`
|
||||||
|
|
||||||
|
## pre-commit
|
||||||
|
This project uses [**pre-commit**](https://pre-commit.com/) to run automatic formatting and security checks before each commit (Black, Bandit, and various safety checks).
|
||||||
|
|
||||||
|
To enable it:
|
||||||
|
```
|
||||||
|
poetry install
|
||||||
|
poetry run pre-commit install
|
||||||
|
```
|
||||||
|
This ensures consistent formatting, catches common issues early, and keeps the codebase clean.
|
||||||
|
|||||||
200
poetry.lock
generated
Normal file
200
poetry.lock
generated
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand.
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfgv"
|
||||||
|
version = "3.4.0"
|
||||||
|
description = "Validate configuration and produce human readable error messages."
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.8"
|
||||||
|
files = [
|
||||||
|
{file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"},
|
||||||
|
{file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "distlib"
|
||||||
|
version = "0.4.0"
|
||||||
|
description = "Distribution utilities"
|
||||||
|
optional = false
|
||||||
|
python-versions = "*"
|
||||||
|
files = [
|
||||||
|
{file = "distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16"},
|
||||||
|
{file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "filelock"
|
||||||
|
version = "3.20.0"
|
||||||
|
description = "A platform independent file lock."
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.10"
|
||||||
|
files = [
|
||||||
|
{file = "filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2"},
|
||||||
|
{file = "filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "identify"
|
||||||
|
version = "2.6.15"
|
||||||
|
description = "File identification library for Python"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.9"
|
||||||
|
files = [
|
||||||
|
{file = "identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757"},
|
||||||
|
{file = "identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
license = ["ukkonen"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nodeenv"
|
||||||
|
version = "1.9.1"
|
||||||
|
description = "Node.js virtual environment builder"
|
||||||
|
optional = false
|
||||||
|
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
|
||||||
|
files = [
|
||||||
|
{file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"},
|
||||||
|
{file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "platformdirs"
|
||||||
|
version = "4.5.0"
|
||||||
|
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.10"
|
||||||
|
files = [
|
||||||
|
{file = "platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3"},
|
||||||
|
{file = "platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
docs = ["furo (>=2025.9.25)", "proselint (>=0.14)", "sphinx (>=8.2.3)", "sphinx-autodoc-typehints (>=3.2)"]
|
||||||
|
test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.4.2)", "pytest-cov (>=7)", "pytest-mock (>=3.15.1)"]
|
||||||
|
type = ["mypy (>=1.18.2)"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pre-commit"
|
||||||
|
version = "3.8.0"
|
||||||
|
description = "A framework for managing and maintaining multi-language pre-commit hooks."
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.9"
|
||||||
|
files = [
|
||||||
|
{file = "pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f"},
|
||||||
|
{file = "pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
cfgv = ">=2.0.0"
|
||||||
|
identify = ">=1.0.0"
|
||||||
|
nodeenv = ">=0.11.1"
|
||||||
|
pyyaml = ">=5.1"
|
||||||
|
virtualenv = ">=20.10.0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyyaml"
|
||||||
|
version = "6.0.3"
|
||||||
|
description = "YAML parser and emitter for Python"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.8"
|
||||||
|
files = [
|
||||||
|
{file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"},
|
||||||
|
{file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"},
|
||||||
|
{file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"},
|
||||||
|
{file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"},
|
||||||
|
{file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"},
|
||||||
|
{file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"},
|
||||||
|
{file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"},
|
||||||
|
{file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"},
|
||||||
|
{file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"},
|
||||||
|
{file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"},
|
||||||
|
{file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"},
|
||||||
|
{file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"},
|
||||||
|
{file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"},
|
||||||
|
{file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"},
|
||||||
|
{file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"},
|
||||||
|
{file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"},
|
||||||
|
{file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"},
|
||||||
|
{file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"},
|
||||||
|
{file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"},
|
||||||
|
{file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"},
|
||||||
|
{file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"},
|
||||||
|
{file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"},
|
||||||
|
{file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"},
|
||||||
|
{file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"},
|
||||||
|
{file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"},
|
||||||
|
{file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"},
|
||||||
|
{file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"},
|
||||||
|
{file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"},
|
||||||
|
{file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"},
|
||||||
|
{file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"},
|
||||||
|
{file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"},
|
||||||
|
{file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"},
|
||||||
|
{file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"},
|
||||||
|
{file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"},
|
||||||
|
{file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"},
|
||||||
|
{file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"},
|
||||||
|
{file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"},
|
||||||
|
{file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"},
|
||||||
|
{file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"},
|
||||||
|
{file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"},
|
||||||
|
{file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"},
|
||||||
|
{file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"},
|
||||||
|
{file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"},
|
||||||
|
{file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"},
|
||||||
|
{file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"},
|
||||||
|
{file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"},
|
||||||
|
{file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"},
|
||||||
|
{file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"},
|
||||||
|
{file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"},
|
||||||
|
{file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"},
|
||||||
|
{file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"},
|
||||||
|
{file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"},
|
||||||
|
{file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"},
|
||||||
|
{file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"},
|
||||||
|
{file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"},
|
||||||
|
{file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"},
|
||||||
|
{file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"},
|
||||||
|
{file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"},
|
||||||
|
{file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"},
|
||||||
|
{file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"},
|
||||||
|
{file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"},
|
||||||
|
{file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"},
|
||||||
|
{file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"},
|
||||||
|
{file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"},
|
||||||
|
{file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"},
|
||||||
|
{file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"},
|
||||||
|
{file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"},
|
||||||
|
{file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"},
|
||||||
|
{file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"},
|
||||||
|
{file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"},
|
||||||
|
{file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"},
|
||||||
|
{file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"},
|
||||||
|
{file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "virtualenv"
|
||||||
|
version = "20.35.4"
|
||||||
|
description = "Virtual Python Environment builder"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.8"
|
||||||
|
files = [
|
||||||
|
{file = "virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b"},
|
||||||
|
{file = "virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
distlib = ">=0.3.7,<1"
|
||||||
|
filelock = ">=3.12.2,<4"
|
||||||
|
platformdirs = ">=3.9.1,<5"
|
||||||
|
|
||||||
|
[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)"]
|
||||||
|
test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"]
|
||||||
|
|
||||||
|
[metadata]
|
||||||
|
lock-version = "2.0"
|
||||||
|
python-versions = "^3.13"
|
||||||
|
content-hash = "0863809e99ebce15ac434c3d43856decb487945ff712f83073c120ee4dc7b850"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "resrm"
|
name = "resrm"
|
||||||
version = "0.1.1"
|
version = "0.3.1"
|
||||||
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"
|
||||||
@@ -12,9 +12,16 @@ packages = [{include = "resrm", from = "src"}]
|
|||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
python = "^3.13"
|
python = "^3.13"
|
||||||
|
|
||||||
|
[tool.poetry.dev-dependencies]
|
||||||
|
pre-commit = "^3.8"
|
||||||
|
|
||||||
[tool.poetry.scripts]
|
[tool.poetry.scripts]
|
||||||
resrm = "resrm.cli:main"
|
resrm = "resrm.cli:main"
|
||||||
|
|
||||||
|
[tool.black]
|
||||||
|
line-length = 79
|
||||||
|
target-version = ["py313"]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry-core"]
|
requires = ["poetry-core"]
|
||||||
build-backend = "poetry.core.masonry.api"
|
build-backend = "poetry.core.masonry.api"
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"release-type": "python",
|
|
||||||
"package-name": "resrm",
|
|
||||||
"changelog-sections": [
|
|
||||||
{ "type": "feat", "section": "Added", "hidden": false },
|
|
||||||
{ "type": "fix", "section": "Fixed", "hidden": false },
|
|
||||||
{ "type": "refactor", "section": "Changed", "hidden": false },
|
|
||||||
{ "type": "perf", "section": "Changed", "hidden": false },
|
|
||||||
{ "type": "docs", "section": "Documentation", "hidden": false }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
# resrm
|
|
||||||
|
|
||||||
**resrm** is a safe, drop-in replacement for the Linux `rm` command with **undo/restore support**.
|
|
||||||
It moves files to a per-user _trash_ instead of permanently deleting them, while still allowing full `sudo` support for root-owned files.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- Move files and directories to a **trash folder** instead of permanent deletion
|
|
||||||
- Restore deleted files by **short ID or exact basename**
|
|
||||||
- Empty trash safely
|
|
||||||
- Supports `-r`, `-f`, `-i`, `--perma` options
|
|
||||||
- Works with `sudo` for root-owned files
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
**NOTE:** To use `resrm` with `sudo`, the path to `resrm` must be in the `$PATH` seen by `root`.\
|
|
||||||
Either install `resrm` as `root` (_preferred_), use `sudo -E resrm`, or add the `$PATH` to `/etc/sudoers` using its `Defaults secure_path` parameter.
|
|
||||||
|
|
||||||
Install via PyPI (_preferred_):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install resrm
|
|
||||||
```
|
|
||||||
|
|
||||||
Or clone the repo and install locally:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/mdaleo404/resrm.git
|
|
||||||
cd resrm/resrm
|
|
||||||
poetry install
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Move files to trash
|
|
||||||
resrm file1 file2
|
|
||||||
|
|
||||||
# Recursive remove of a directory
|
|
||||||
resrm -r mydir
|
|
||||||
|
|
||||||
# Force remove (ignore nonexistent)
|
|
||||||
resrm -f file
|
|
||||||
|
|
||||||
# Interactive remove
|
|
||||||
resrm -i file
|
|
||||||
|
|
||||||
# Permanent delete (bypass trash)
|
|
||||||
resrm --perma file
|
|
||||||
|
|
||||||
# List trash entries
|
|
||||||
resrm -l
|
|
||||||
|
|
||||||
# Restore a file by ID or basename
|
|
||||||
resrm --restore <id|name>
|
|
||||||
|
|
||||||
# Empty the trash permanently
|
|
||||||
resrm --empty
|
|
||||||
```
|
|
||||||
|
|
||||||
## Trash Location
|
|
||||||
|
|
||||||
Normal users: `~/.local/share/resrm/files`
|
|
||||||
|
|
||||||
Root user: `/root/.local/share/resrm/files`
|
|
||||||
7
resrm/poetry.lock
generated
7
resrm/poetry.lock
generated
@@ -1,7 +0,0 @@
|
|||||||
# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand.
|
|
||||||
package = []
|
|
||||||
|
|
||||||
[metadata]
|
|
||||||
lock-version = "2.0"
|
|
||||||
python-versions = "^3.13"
|
|
||||||
content-hash = "f01b553f3895e558c34b4f10542e05acdef39bf0527c8090bd136d914dc73f94"
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
from resrm.core import main
|
|
||||||
1
src/resrm/cli.py
Normal file
1
src/resrm/cli.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from resrm.core import main
|
||||||
@@ -7,7 +7,7 @@ Basic usage:
|
|||||||
resrm -r dir # recursive remove (moves dir to trash)
|
resrm -r dir # recursive remove (moves dir to trash)
|
||||||
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 --perma file # permanent delete (bypass trash)
|
resrm --skip-trash file # permanent delete (bypass trash)
|
||||||
resrm -l # list trash entries (neat table)
|
resrm -l # 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 --empty # empty trash entries (permanent)
|
resrm --empty # empty trash entries (permanent)
|
||||||
@@ -22,10 +22,19 @@ import sys
|
|||||||
import uuid
|
import uuid
|
||||||
import datetime
|
import datetime
|
||||||
import textwrap
|
import textwrap
|
||||||
|
import importlib.metadata
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Dict, Optional
|
from typing import List, Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
# Config
|
# Config
|
||||||
|
def get_version():
|
||||||
|
try:
|
||||||
|
return importlib.metadata.version("resrm")
|
||||||
|
except importlib.metadata.PackageNotFoundError:
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
def get_trash_base_for_user(uid: int) -> Path:
|
def get_trash_base_for_user(uid: int) -> Path:
|
||||||
"""Return the trash base path depending on whether user is root or normal."""
|
"""Return the trash base path depending on whether user is root or normal."""
|
||||||
if uid == 0:
|
if uid == 0:
|
||||||
@@ -33,6 +42,7 @@ def get_trash_base_for_user(uid: int) -> Path:
|
|||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
import pwd
|
import pwd
|
||||||
|
|
||||||
user_info = pwd.getpwuid(uid)
|
user_info = pwd.getpwuid(uid)
|
||||||
home_dir = Path(user_info.pw_dir)
|
home_dir = Path(user_info.pw_dir)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -53,6 +63,46 @@ def get_trash_paths() -> tuple[Path, Path]:
|
|||||||
TRASH_DIR, META_FILE = get_trash_paths()
|
TRASH_DIR, META_FILE = get_trash_paths()
|
||||||
DATEFMT = "%Y-%m-%d %H:%M"
|
DATEFMT = "%Y-%m-%d %H:%M"
|
||||||
|
|
||||||
|
|
||||||
|
def prune_old_trash():
|
||||||
|
"""Remove trash entries older than RESRM_TRASH_LIFE days (default 7)."""
|
||||||
|
try:
|
||||||
|
life_days = int(os.environ.get("RESRM_TRASH_LIFE", "7"))
|
||||||
|
except ValueError:
|
||||||
|
life_days = 7
|
||||||
|
|
||||||
|
if life_days < 1:
|
||||||
|
life_days = 1
|
||||||
|
|
||||||
|
cutoff = datetime.datetime.now() - datetime.timedelta(days=life_days)
|
||||||
|
removed = 0
|
||||||
|
|
||||||
|
for entry in list(meta): # make copy since we'll modify meta
|
||||||
|
try:
|
||||||
|
ts = datetime.datetime.fromisoformat(entry["timestamp"])
|
||||||
|
except Exception:
|
||||||
|
continue # skip malformed entries
|
||||||
|
|
||||||
|
if ts < cutoff:
|
||||||
|
f = TRASH_DIR / entry["id"]
|
||||||
|
try:
|
||||||
|
if f.exists():
|
||||||
|
if f.is_dir():
|
||||||
|
shutil.rmtree(f, ignore_errors=True)
|
||||||
|
else:
|
||||||
|
f.unlink(missing_ok=True)
|
||||||
|
meta.remove(entry)
|
||||||
|
removed += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to prune {f}: {e}")
|
||||||
|
|
||||||
|
if removed > 0:
|
||||||
|
save_meta(meta)
|
||||||
|
print(
|
||||||
|
f"Pruned {removed} trash entr{'y' if removed == 1 else 'ies'} older than {life_days} da{'y' if life_days == 1 else 'ys'}."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def load_meta() -> List[Dict]:
|
def load_meta() -> List[Dict]:
|
||||||
if META_FILE.exists():
|
if META_FILE.exists():
|
||||||
try:
|
try:
|
||||||
@@ -62,15 +112,19 @@ def load_meta() -> List[Dict]:
|
|||||||
return []
|
return []
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def save_meta(meta: List[Dict]):
|
def save_meta(meta: List[Dict]):
|
||||||
with META_FILE.open("w", encoding="utf-8") as f:
|
with META_FILE.open("w", encoding="utf-8") as f:
|
||||||
json.dump(meta, f, indent=2, ensure_ascii=False)
|
json.dump(meta, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
meta = load_meta()
|
meta = load_meta()
|
||||||
|
|
||||||
|
|
||||||
def short_id(fullid: str) -> str:
|
def short_id(fullid: str) -> str:
|
||||||
return fullid[:8]
|
return fullid[:8]
|
||||||
|
|
||||||
|
|
||||||
def human_time(ts: str) -> str:
|
def human_time(ts: str) -> str:
|
||||||
"""
|
"""
|
||||||
Convert ISO timestamp string from metadata to a human-readable format.
|
Convert ISO timestamp string from metadata to a human-readable format.
|
||||||
@@ -82,13 +136,15 @@ def human_time(ts: str) -> str:
|
|||||||
# Fallback: just return the raw string
|
# Fallback: just return the raw string
|
||||||
return ts
|
return ts
|
||||||
|
|
||||||
|
|
||||||
def entry_display(entry: Dict, width: int = 80) -> str:
|
def entry_display(entry: Dict, width: int = 80) -> str:
|
||||||
id8 = short_id(entry["id"])
|
id8 = short_id(entry["id"])
|
||||||
ts = human_time(entry["timestamp"])
|
ts = human_time(entry["timestamp"])
|
||||||
path = entry["orig_path"]
|
path = entry["orig_path"]
|
||||||
wrapped = textwrap.fill(path, width=width-32)
|
wrapped = textwrap.fill(path, width=width - 32)
|
||||||
return f"{id8:<8} {ts:<19} {wrapped}"
|
return f"{id8:<8} {ts:<19} {wrapped}"
|
||||||
|
|
||||||
|
|
||||||
def list_trash():
|
def list_trash():
|
||||||
if not meta:
|
if not meta:
|
||||||
print("Trash empty.")
|
print("Trash empty.")
|
||||||
@@ -96,16 +152,17 @@ def list_trash():
|
|||||||
|
|
||||||
header = f"{'ID':<8} {'Deleted at':<19} {'Original path'}"
|
header = f"{'ID':<8} {'Deleted at':<19} {'Original path'}"
|
||||||
print(header)
|
print(header)
|
||||||
print('-' * len(header))
|
print("-" * len(header))
|
||||||
for entry in meta:
|
for entry in meta:
|
||||||
id8 = short_id(entry["id"])
|
id8 = short_id(entry["id"])
|
||||||
ts = human_time(entry["timestamp"])
|
ts = human_time(entry["timestamp"])
|
||||||
path = entry["orig_path"]
|
path = entry["orig_path"]
|
||||||
max_path_len = 80
|
max_path_len = 80
|
||||||
if len(path) > max_path_len:
|
if len(path) > max_path_len:
|
||||||
path = "…" + path[-(max_path_len - 1):]
|
path = "…" + path[-(max_path_len - 1) :]
|
||||||
print(f"{id8:<8} {ts:<19} {path}")
|
print(f"{id8:<8} {ts:<19} {path}")
|
||||||
|
|
||||||
|
|
||||||
def find_candidates(identifier: str) -> List[Dict]:
|
def find_candidates(identifier: str) -> List[Dict]:
|
||||||
# exact basename match first
|
# exact basename match first
|
||||||
exact = [m for m in meta if Path(m["orig_path"]).name == identifier]
|
exact = [m for m in meta if Path(m["orig_path"]).name == identifier]
|
||||||
@@ -116,6 +173,9 @@ def find_candidates(identifier: str) -> List[Dict]:
|
|||||||
if id_matches:
|
if id_matches:
|
||||||
return id_matches
|
return id_matches
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
def restore_many(identifiers: List[str]):
|
def restore_many(identifiers: List[str]):
|
||||||
"""Restore multiple files, prompting when needed."""
|
"""Restore multiple files, prompting when needed."""
|
||||||
for identifier in identifiers:
|
for identifier in identifiers:
|
||||||
@@ -133,7 +193,9 @@ def restore_many(identifiers: List[str]):
|
|||||||
# Multiple matches - prompt user
|
# Multiple matches - prompt user
|
||||||
print(f"Multiple matches for '{identifier}':")
|
print(f"Multiple matches for '{identifier}':")
|
||||||
for i, entry in enumerate(candidates, start=1):
|
for i, entry in enumerate(candidates, start=1):
|
||||||
print(f"{i}) {short_id(entry['id'])} {entry['orig_path']} ({entry['timestamp']})")
|
print(
|
||||||
|
f"{i}) {short_id(entry['id'])} {entry['orig_path']} ({entry['timestamp']})"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
choice = input("Choose number to restore (or skip): ").strip()
|
choice = input("Choose number to restore (or skip): ").strip()
|
||||||
@@ -151,6 +213,7 @@ def restore_many(identifiers: List[str]):
|
|||||||
else:
|
else:
|
||||||
print("Invalid selection. Skipped.")
|
print("Invalid selection. Skipped.")
|
||||||
|
|
||||||
|
|
||||||
def restore_one(entry: Dict) -> bool:
|
def restore_one(entry: Dict) -> bool:
|
||||||
src = TRASH_DIR / entry["id"]
|
src = TRASH_DIR / entry["id"]
|
||||||
dest = Path(entry["orig_path"])
|
dest = Path(entry["orig_path"])
|
||||||
@@ -172,6 +235,7 @@ def restore_one(entry: Dict) -> bool:
|
|||||||
print(f"Restored to: {dest}")
|
print(f"Restored to: {dest}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def restore(identifier: str):
|
def restore(identifier: str):
|
||||||
candidates = find_candidates(identifier)
|
candidates = find_candidates(identifier)
|
||||||
if not candidates:
|
if not candidates:
|
||||||
@@ -183,7 +247,9 @@ def restore(identifier: str):
|
|||||||
# multiple candidates -> show list and ask
|
# multiple candidates -> show list and ask
|
||||||
print("Multiple matches:")
|
print("Multiple matches:")
|
||||||
for i, e in enumerate(candidates, start=1):
|
for i, e in enumerate(candidates, start=1):
|
||||||
print(f"{i}) {short_id(e['id'])} {e['orig_path']} ({e['timestamp']})")
|
print(
|
||||||
|
f"{i}) {short_id(e['id'])} {e['orig_path']} ({e['timestamp']})"
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
choice = input("Choose number to restore (or abort): ").strip()
|
choice = input("Choose number to restore (or abort): ").strip()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
@@ -198,6 +264,7 @@ def restore(identifier: str):
|
|||||||
return
|
return
|
||||||
restore_one(candidates[idx])
|
restore_one(candidates[idx])
|
||||||
|
|
||||||
|
|
||||||
def empty_trash():
|
def empty_trash():
|
||||||
"""Permanently remove all trashed files and clear metadata."""
|
"""Permanently remove all trashed files and clear metadata."""
|
||||||
count = 0
|
count = 0
|
||||||
@@ -216,7 +283,10 @@ def empty_trash():
|
|||||||
save_meta(meta)
|
save_meta(meta)
|
||||||
print(f"Trash emptied ({count} entries removed).")
|
print(f"Trash emptied ({count} entries removed).")
|
||||||
|
|
||||||
def move_to_trash(path: Path, interactive: bool, force: bool, recursive: bool, perma: bool):
|
|
||||||
|
def move_to_trash(
|
||||||
|
path: Path, interactive: bool, force: bool, skip_trash: bool
|
||||||
|
):
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
if force:
|
if force:
|
||||||
return
|
return
|
||||||
@@ -224,7 +294,7 @@ def move_to_trash(path: Path, interactive: bool, force: bool, recursive: bool, p
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Interactive prompt
|
# Interactive prompt
|
||||||
if interactive:
|
if interactive and not force:
|
||||||
try:
|
try:
|
||||||
yn = input(f"remove '{path}'? [y/N] ").strip().lower()
|
yn = input(f"remove '{path}'? [y/N] ").strip().lower()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
@@ -234,7 +304,7 @@ def move_to_trash(path: Path, interactive: bool, force: bool, recursive: bool, p
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Permanent delete path
|
# Permanent delete path
|
||||||
if perma:
|
if skip_trash:
|
||||||
try:
|
try:
|
||||||
if path.is_dir() and not path.is_symlink():
|
if path.is_dir() and not path.is_symlink():
|
||||||
shutil.rmtree(path)
|
shutil.rmtree(path)
|
||||||
@@ -244,18 +314,21 @@ def move_to_trash(path: Path, interactive: bool, force: bool, recursive: bool, p
|
|||||||
print(f"Failed permanent delete: {e}")
|
print(f"Failed permanent delete: {e}")
|
||||||
return
|
return
|
||||||
|
|
||||||
# 🚫 Prevent non-root user deleting root-owned files
|
# Prevent non-root user deleting root-owned files
|
||||||
try:
|
try:
|
||||||
st = path.stat()
|
st = path.stat()
|
||||||
if st.st_uid == 0 and os.geteuid() != 0:
|
if st.st_uid == 0 and os.geteuid() != 0:
|
||||||
print(f"resrm: permission denied: '{path}' (root-owned file, try sudo)")
|
print(
|
||||||
|
f"resrm: permission denied: '{path}' (root-owned file, try sudo)"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# 🧭 Detect which trash to use (based on file owner)
|
# Detect which trash to use (based on file owner)
|
||||||
try:
|
try:
|
||||||
import pwd
|
import pwd
|
||||||
|
|
||||||
owner_uid = path.stat().st_uid
|
owner_uid = path.stat().st_uid
|
||||||
owner_info = pwd.getpwuid(owner_uid)
|
owner_info = pwd.getpwuid(owner_uid)
|
||||||
owner_home = Path(owner_info.pw_dir)
|
owner_home = Path(owner_info.pw_dir)
|
||||||
@@ -291,7 +364,7 @@ def move_to_trash(path: Path, interactive: bool, force: bool, recursive: bool, p
|
|||||||
entry = {
|
entry = {
|
||||||
"id": uid,
|
"id": uid,
|
||||||
"orig_path": str(path.resolve()),
|
"orig_path": str(path.resolve()),
|
||||||
"timestamp": datetime.datetime.now().isoformat()
|
"timestamp": datetime.datetime.now().isoformat(),
|
||||||
}
|
}
|
||||||
owner_meta.append(entry)
|
owner_meta.append(entry)
|
||||||
with meta_file.open("w", encoding="utf-8") as f:
|
with meta_file.open("w", encoding="utf-8") as f:
|
||||||
@@ -299,26 +372,45 @@ def move_to_trash(path: Path, interactive: bool, force: bool, recursive: bool, p
|
|||||||
|
|
||||||
print(f"Removed '{path}' -> trash id {short_id(uid)}")
|
print(f"Removed '{path}' -> trash id {short_id(uid)}")
|
||||||
|
|
||||||
|
|
||||||
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:]
|
||||||
|
prune_old_trash()
|
||||||
parser = argparse.ArgumentParser(add_help=False)
|
parser = argparse.ArgumentParser(add_help=False)
|
||||||
parser.add_argument("paths", nargs="*", help="files to remove")
|
parser.add_argument("paths", nargs="*", help="files to remove")
|
||||||
parser.add_argument("-r", action="store_true", help="recursive")
|
parser.add_argument("-r", action="store_true", help="recursive")
|
||||||
parser.add_argument("-f", action="store_true", help="force")
|
parser.add_argument("-f", "--force", action="store_true", help="force")
|
||||||
parser.add_argument("-i", action="store_true", help="interactive")
|
parser.add_argument("-i", action="store_true", help="interactive")
|
||||||
parser.add_argument("--perma", action="store_true", help="permanent delete")
|
parser.add_argument(
|
||||||
parser.add_argument("--restore", nargs="+", help="restore by id or basename")
|
"--skip-trash", action="store_true", help="permanent delete"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--restore",
|
||||||
|
nargs="+",
|
||||||
|
metavar="item",
|
||||||
|
help="restore by id or basename",
|
||||||
|
)
|
||||||
parser.add_argument("-l", action="store_true", help="list trash")
|
parser.add_argument("-l", action="store_true", help="list trash")
|
||||||
parser.add_argument("--empty", action="store_true", help="empty the trash permanently")
|
parser.add_argument(
|
||||||
parser.add_argument("--help", action="store_true", help="show help")
|
"--empty", action="store_true", help="empty the trash permanently"
|
||||||
|
)
|
||||||
|
parser.add_argument("-h", "--help", action="store_true", help="show help")
|
||||||
|
parser.add_argument(
|
||||||
|
"-V", "--version", action="version", version=f"resrm {get_version()}"
|
||||||
|
)
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
# Always print docstring if --help or no args
|
# Always print docstring if -h or --help
|
||||||
if args.help or not argv:
|
if args.help:
|
||||||
print(__doc__)
|
print(__doc__)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if not args.paths and not (args.l or args.empty or args.restore):
|
||||||
|
print("resrm: missing operand")
|
||||||
|
print("Try 'resrm --help' for more information.")
|
||||||
|
return
|
||||||
|
|
||||||
if args.l:
|
if args.l:
|
||||||
list_trash()
|
list_trash()
|
||||||
return
|
return
|
||||||
@@ -344,4 +436,9 @@ def main(argv: Optional[List[str]] = None):
|
|||||||
continue
|
continue
|
||||||
print(f"resrm: cannot remove '{pth}': Is a directory")
|
print(f"resrm: cannot remove '{pth}': Is a directory")
|
||||||
continue
|
continue
|
||||||
move_to_trash(pth, interactive=args.i, force=args.f, recursive=args.r, perma=args.perma)
|
move_to_trash(
|
||||||
|
pth,
|
||||||
|
interactive=args.i,
|
||||||
|
force=args.force,
|
||||||
|
skip_trash=args.skip_trash,
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user