chore: optimize pre-commit, remove tracked binaries, fix lint issues

- Move slow hooks (mypy, pylint, bandit, pytest, prettier) to pre-push stage
- Remove redundant autoflake (ruff covers F401/F841)
- Fix shellcheck OOM by batching files with xargs -n 40
- Remove tracked .o, .wav, .pyc binaries from git
- Move pomodoro wav files to ../testsAndMisc_binaries/ with symlinks
- Add *.o, *.so, *.a to .gitignore
- Refactor hltb._pick_best_hltb_entry to fix C901/PLR0911/SIM102
- Fix SC2034 warnings in gif_to_square.sh and upgrade.sh
- Add disk_cleanup_check.sh script
- Various test and code improvements across screen_locker,
  steam_backlog_enforcer, word_frequency, moviepy_showcase
This commit is contained in:
Krzysztof kuhy Rudnicki 2026-04-10 18:44:51 +02:00
parent 7f2d2c4c39
commit 3ebb97b283
44 changed files with 971 additions and 237 deletions

View File

@ -117,6 +117,11 @@ C/misc/randomJPG/
# Files with many embedded image references
python_pkg/cinema_planner/pasted_content.txt
# Large data files (exceed indexing limits)
python_pkg/keyboard_coop/words_dictionary.json
linux_configuration/scripts/digital_wellbeing/pacman/words.txt
python_pkg/word_frequency/test_texts/
# C build artifacts
**/*.out
**/random_engine

14
.gitignore vendored
View File

@ -102,6 +102,9 @@ Thumbs.db
*.sqlite
*.sqlite3
# Compiled
*.o
*.so
*.a
*.exe
*.dll
*.dylib
@ -110,7 +113,16 @@ Thumbs.db
!pomodoro_app/android/app/src/main/res/mipmap-*/ic_launcher.png
!pomodoro_app/packaging/arch/pomodoro-app.svg
!TS/battery-status/public/favicon.svg
!horatio/horatio_app/assets/demo_recordings/*.wav
# Binary asset symlinks (point to ../testsAndMisc_binaries/)
pomodoro_app/assets/sounds/long_break_done.wav
pomodoro_app/assets/sounds/long_break_start.wav
pomodoro_app/assets/sounds/short_break_done.wav
pomodoro_app/assets/sounds/work_done.wav
horatio/horatio_app/assets/demo_recordings/hamlet_line0_take1.wav
horatio/horatio_app/assets/demo_recordings/hamlet_line0_take2.wav
horatio/horatio_app/assets/demo_recordings/hamlet_line0_take3.wav
horatio/horatio_app/assets/demo_recordings/hamlet_line1_take1.wav
# ===========================================================================

View File

@ -1,9 +1,10 @@
# ==============================================================================
# Pre-commit Configuration - Multi-language Linting & Formatting
# ==============================================================================
# Install: pre-commit install
# Run all: pre-commit run --all-files
# Update hooks: pre-commit autoupdate
# Install: pre-commit install && pre-commit install --hook-type pre-push
# Fast lint: pre-commit run --all-files (linters only, ~10 s)
# Full suite: pre-commit run --all-files --hook-stage pre-push (+ tests)
# Update hooks: pre-commit autoupdate
# ==============================================================================
# Global settings
@ -101,12 +102,13 @@ repos:
types_or: [python, pyi]
# ===========================================================================
# MYPY - Static type checking (minimal for scripts repository)
# MYPY - Static type checking (runs on push only for speed)
# ===========================================================================
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.13.0
hooks:
- id: mypy
stages: [pre-push]
args:
- --ignore-missing-imports
- --no-error-summary
@ -144,12 +146,13 @@ repos:
- types-python-dateutil
# ===========================================================================
# PYLINT - Comprehensive Python linter
# PYLINT - Comprehensive Python linter (runs on push only for speed)
# ===========================================================================
- repo: https://github.com/pylint-dev/pylint
rev: v3.3.2
hooks:
- id: pylint
stages: [pre-push]
args:
- --rcfile=pyproject.toml
- --fail-under=8.0
@ -162,12 +165,13 @@ repos:
exclude: ^(Bash/|\.venv/)
# ===========================================================================
# BANDIT - Security linter (relaxed for scripts)
# BANDIT - Security linter (runs on push only for speed)
# ===========================================================================
- repo: https://github.com/PyCQA/bandit
rev: 1.7.10
hooks:
- id: bandit
stages: [pre-push]
args:
- -c
- pyproject.toml
@ -180,6 +184,7 @@ repos:
# ===========================================================================
# PYTEST + COVERAGE - Run tests and enforce 100% code coverage
# Only tests for subpackages with changed files are run (see script).
# Runs on push only (slow); use --hook-stage pre-push to run manually.
# ===========================================================================
- repo: local
hooks:
@ -189,6 +194,7 @@ repos:
language: system
types: [python]
pass_filenames: true
stages: [pre-push]
# ===========================================================================
# VULTURE - Dead code detection (disabled - doesn't work well with pre-commit)
@ -259,17 +265,18 @@ repos:
# ===========================================================================
# AUTOFLAKE - Remove unused imports/variables
# Disabled: fully redundant with ruff (F401, F841, F811) + --fix
# ===========================================================================
- repo: https://github.com/PyCQA/autoflake
rev: v2.3.1
hooks:
- id: autoflake
args:
- --in-place
- --remove-all-unused-imports
- --remove-unused-variables
- --remove-duplicate-keys
- --expand-star-imports
# - repo: https://github.com/PyCQA/autoflake
# rev: v2.3.1
# hooks:
# - id: autoflake
# args:
# - --in-place
# - --remove-all-unused-imports
# - --remove-unused-variables
# - --remove-duplicate-keys
# - --expand-star-imports
# ===========================================================================
# SAFETY - Check for security vulnerabilities in dependencies
@ -291,7 +298,7 @@ repos:
# - id: pyright
# ===========================================================================
# CHECK JSON/YAML/TOML formatting
# CHECK JSON/YAML/TOML formatting (runs on push only — slow Node.js startup)
# ===========================================================================
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v4.0.0-alpha.8
@ -299,15 +306,19 @@ repos:
- id: prettier
types_or: [yaml, json, markdown]
exclude: ^(Bash/|\.venv/|.*\.lock$|C/compile_commands\.json)
stages: [pre-push]
# ===========================================================================
# SHELLCHECK - Shell script linting
# Wrapper batches files to avoid OOM on large repos.
# ===========================================================================
- repo: https://github.com/shellcheck-py/shellcheck-py
rev: v0.10.0.1
- repo: local
hooks:
- id: shellcheck
args: [--severity=warning]
name: shellcheck
entry: bash -c 'printf "%s\0" "$@" | xargs -0 -n 40 shellcheck --severity=warning' --
language: system
types: [shell]
exclude: ^pomodoro_app/
# ===========================================================================
@ -427,7 +438,7 @@ repos:
# stages: [push]
# ===========================================================================
# POMODORO APP - Flutter analyze + test
# POMODORO APP - Flutter analyze + test (push only)
# ===========================================================================
- repo: local
hooks:
@ -437,9 +448,10 @@ repos:
language: system
files: ^pomodoro_app/
pass_filenames: false
stages: [pre-push]
# ===========================================================================
# HORATIO - Dart/Flutter tests with coverage enforcement
# HORATIO - Dart/Flutter tests with coverage enforcement (push only)
# ===========================================================================
- repo: local
hooks:
@ -448,4 +460,5 @@ repos:
entry: bash -c 'cd horatio && bash run.sh test'
language: system
files: ^horatio/
stages: [pre-push]
pass_filenames: false

View File

@ -16,7 +16,7 @@ Checks: >
-readability-isolate-declaration,
-clang-analyzer-security.insecureAPI.DeprecatedOrUnsafeBufferHandling
WarningsAsErrors: ''
WarningsAsErrors: ""
HeaderFilterRegex: '.*\.h$'
AnalyzeTemporaryDtors: false
FormatStyle: file

View File

@ -1,43 +1,35 @@
{
"C_Cpp.default.cStandard": "c99",
"C_Cpp.default.intelliSenseMode": "linux-gcc-x64",
"C_Cpp.default.includePath": [
"${workspaceFolder}/**",
"/usr/include/SDL2"
],
"C_Cpp.default.defines": [
"_REENTRANT"
],
"C_Cpp.default.compilerPath": "/usr/bin/gcc",
"C_Cpp.clang_format_style": "file",
"C_Cpp.clang_format_fallbackStyle": "LLVM",
"C_Cpp.default.cStandard": "c99",
"C_Cpp.default.intelliSenseMode": "linux-gcc-x64",
"C_Cpp.default.includePath": ["${workspaceFolder}/**", "/usr/include/SDL2"],
"C_Cpp.default.defines": ["_REENTRANT"],
"C_Cpp.default.compilerPath": "/usr/bin/gcc",
"C_Cpp.clang_format_style": "file",
"C_Cpp.clang_format_fallbackStyle": "LLVM",
"files.associations": {
"*.h": "c",
"*.c": "c"
},
"files.associations": {
"*.h": "c",
"*.c": "c"
},
"editor.formatOnSave": true,
"editor.tabSize": 4,
"editor.insertSpaces": true,
"editor.rulers": [100],
"editor.formatOnSave": true,
"editor.tabSize": 4,
"editor.insertSpaces": true,
"editor.rulers": [100],
"clang-tidy.executable": "clang-tidy",
"clang-tidy.checks": [
"clang-diagnostic-*",
"clang-analyzer-*",
"bugprone-*",
"cert-*",
"misc-*",
"performance-*",
"portability-*",
"readability-*"
],
"clang-tidy.executable": "clang-tidy",
"clang-tidy.checks": [
"clang-diagnostic-*",
"clang-analyzer-*",
"bugprone-*",
"cert-*",
"misc-*",
"performance-*",
"portability-*",
"readability-*"
],
"cppcheck.enable": true,
"cppcheck.standard": ["c99"],
"cppcheck.suppress": [
"missingIncludeSystem",
"unusedFunction"
]
"cppcheck.enable": true,
"cppcheck.standard": ["c99"],
"cppcheck.suppress": ["missingIncludeSystem", "unusedFunction"]
}

View File

@ -5,26 +5,31 @@ This directory contains a comprehensive linting setup for the imageViewer C proj
## Tools Included
### 1. **clang-tidy** - Static Analysis
- Configuration: `.clang-tidy`
- Checks for bugs, performance issues, and style violations
- Enforces modern C coding standards
### 2. **clang-format** - Code Formatting
- Configuration: `.clang-format`
- Automatically formats code to consistent style
- 100-character line limit, 4-space indentation
### 3. **cppcheck** - Additional Static Analysis
- Detects memory leaks, null pointer dereferences
- Checks for undefined behavior
### 4. **gcc with warnings** - Compiler Analysis
- Comprehensive warning flags
- Standards compliance checking
## Usage
### Quick Start
```bash
# Install dependencies (Arch Linux)
make deps-arch
@ -43,6 +48,7 @@ make memcheck
```
### Individual Commands
```bash
# Manual linting
./lint.sh
@ -60,12 +66,14 @@ cppcheck --enable=all main.c
## VS Code Integration
The `.vscode/settings.json` file provides:
- Automatic formatting on save
- C99 standard compliance
- IntelliSense configuration for SDL2
- Integrated linting with clang-tidy and cppcheck
## Recommended Extensions for VS Code
- C/C++ (Microsoft)
- clang-tidy (mine-cetinkaya-fianso)
- cppcheck (unixwrapped)
@ -73,16 +81,18 @@ The `.vscode/settings.json` file provides:
## Linting Rules
### Enabled Checks
- **clang-diagnostic-***: Compiler diagnostics
- **clang-analyzer-***: Static analysis
- **bugprone-***: Bug-prone patterns
- **cert-***: CERT secure coding standards
- **misc-***: Miscellaneous checks
- **performance-***: Performance improvements
- **portability-***: Cross-platform issues
- **readability-***: Code readability
- **clang-diagnostic-\***: Compiler diagnostics
- **clang-analyzer-\***: Static analysis
- **bugprone-\***: Bug-prone patterns
- **cert-\***: CERT secure coding standards
- **misc-\***: Miscellaneous checks
- **performance-\***: Performance improvements
- **portability-\***: Cross-platform issues
- **readability-\***: Code readability
### Disabled Checks
- `readability-magic-numbers`: Allows constants like window dimensions
- `cert-err33-c`: Allows ignoring some function return values
- `misc-unused-parameters`: Common in callback functions
@ -107,16 +117,19 @@ The `.vscode/settings.json` file provides:
## Installation on Different Systems
### Arch Linux
```bash
sudo pacman -S clang cppcheck valgrind
```
### Ubuntu/Debian
```bash
sudo apt install clang-tidy cppcheck clang-format valgrind
```
### Fedora/RHEL
```bash
sudo dnf install clang-tools-extra cppcheck clang valgrind
```

View File

@ -16,22 +16,26 @@ A simple, lightweight image viewer written in C using SDL2. Supports JPG, PNG, B
The image viewer requires SDL2 and SDL2_image libraries.
### Ubuntu/Debian
```bash
sudo apt-get update
sudo apt-get install libsdl2-dev libsdl2-image-dev
```
### Fedora/RHEL/CentOS
```bash
sudo dnf install SDL2-devel SDL2_image-devel
```
### Arch Linux
```bash
sudo pacman -S sdl2 sdl2_image
```
### macOS (with Homebrew)
```bash
brew install sdl2 sdl2_image
```
@ -39,6 +43,7 @@ brew install sdl2 sdl2_image
## Installation
### Arch Linux (Automated)
For Arch Linux users, there's an automated installation script that handles everything:
```bash
@ -46,6 +51,7 @@ For Arch Linux users, there's an automated installation script that handles ever
```
This script will:
- Install SDL2 dependencies via pacman
- Build the imageviewer from source
- Install the binary to `/usr/local/bin`
@ -53,6 +59,7 @@ This script will:
- Set up file associations
To uninstall:
```bash
./uninstall_arch.sh
```
@ -61,11 +68,13 @@ To uninstall:
1. Install dependencies (see above)
2. Build the project:
```bash
make
```
Or use the dependency helper:
```bash
make deps-debian # For Ubuntu/Debian
make deps-fedora # For Fedora/RHEL/CentOS
@ -80,6 +89,7 @@ make
```
Example:
```bash
./imageviewer photo.jpg
./imageviewer ../misc/randomJPG/14k/bloated_image_1.jpg
@ -87,35 +97,39 @@ Example:
## Controls
| Control | Action |
|---------|--------|
| **Mouse wheel** | Zoom in/out |
| **+ / -** | Zoom in/out (keyboard) |
| **Mouse drag** | Pan around the image |
| **R** | Reset zoom and position to default |
| **F** | Fit image to window |
| **H** | Show help in console |
| **ESC / Q** | Quit the application |
| Control | Action |
| --------------- | ---------------------------------- |
| **Mouse wheel** | Zoom in/out |
| **+ / -** | Zoom in/out (keyboard) |
| **Mouse drag** | Pan around the image |
| **R** | Reset zoom and position to default |
| **F** | Fit image to window |
| **H** | Show help in console |
| **ESC / Q** | Quit the application |
## Features in Detail
### Zooming
- Use mouse wheel to zoom in/out at the mouse cursor position
- Keyboard shortcuts: `+` to zoom in, `-` to zoom out
- Zoom range: 0.1x to 10x
- Smart zoom behavior focuses on mouse position
### Panning
- Click and drag with left mouse button to move around zoomed images
- Smooth panning for precise positioning
- Works at any zoom level
### Auto-fit
- Large images are automatically scaled to fit the window when first loaded
- Press `F` to manually fit the current image to window
- Maintains aspect ratio
### Window Management
- Resizable window that adapts to content
- Real-time rendering updates
- Dark background for better image contrast
@ -141,7 +155,9 @@ Example:
## Troubleshooting
### "SDL could not initialize" Error
Make sure SDL2 development libraries are installed:
```bash
# Ubuntu/Debian
sudo apt-get install libsdl2-dev libsdl2-image-dev
@ -151,11 +167,13 @@ pkg-config --libs sdl2
```
### "Unable to load image" Error
- Check that the image file exists and is readable
- Verify the image format is supported (JPG, PNG, BMP, GIF, TIF)
- Try with a different image file to isolate the issue
### Compilation Errors
- Ensure you have a C compiler installed (gcc or clang)
- Check that SDL2 headers are available
- Try rebuilding with `make clean && make`

View File

@ -7,6 +7,7 @@ The imageviewer project uses secure coding practices with proper bounds checking
### Why These Warnings Appear
The static analyzer flags standard C library functions like:
- `memcpy()` - suggests using `memcpy_s()`
- `snprintf()` - suggests using `snprintf_s()`
- `strncpy()` - suggests using `strncpy_s()`
@ -34,9 +35,10 @@ if (ret < 0 || ret >= sizeof(full_path)) {
}
```
### Microsoft-Specific _s Functions
### Microsoft-Specific \_s Functions
The suggested `_s` functions (like `memcpy_s`, `snprintf_s`) are:
- Microsoft-specific extensions
- Not part of standard C
- Not portable to Linux/Unix systems
@ -47,6 +49,7 @@ The suggested `_s` functions (like `memcpy_s`, `snprintf_s`) are:
**Status**: ✅ **SECURE**
The current implementation is secure because:
- All buffer operations are bounds-checked
- No user input is directly copied without validation
- File paths are validated for maximum length
@ -63,6 +66,7 @@ For development, these specific warnings can be suppressed since the code has be
```
Or use NOLINT comments for specific lines:
```c
memcpy(dest, src, len); // NOLINT(clang-analyzer-security.insecureAPI.DeprecatedOrUnsafeBufferHandling)
```
@ -70,6 +74,7 @@ memcpy(dest, src, len); // NOLINT(clang-analyzer-security.insecureAPI.Deprecated
### Verification
To verify security:
1. ✅ All string operations use explicit length checking
2. ✅ Buffer overflow conditions are detected and handled
3. ✅ No direct user input to buffer operations

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1 +0,0 @@
../../../../../testsAndMisc_binaries/horatio_demo_recordings/hamlet_line0_take1.wav

View File

@ -1 +0,0 @@
../../../../../testsAndMisc_binaries/horatio_demo_recordings/hamlet_line0_take2.wav

View File

@ -1 +0,0 @@
../../../../../testsAndMisc_binaries/horatio_demo_recordings/hamlet_line0_take3.wav

View File

@ -1 +0,0 @@
../../../../../testsAndMisc_binaries/horatio_demo_recordings/hamlet_line1_take1.wav

View File

@ -66,7 +66,8 @@ fi
INPUT_GIF="$1"
OUTPUT_GIF="${2:-}"
BACKGROUND="${3:-${DEFAULT_BG}}"
BACKGROUND="${3:-${DEFAULT_BG}}" # Reserved for future padding mode
export BACKGROUND
# Validate input file exists
if [[ ! -f ${INPUT_GIF} ]]; then

View File

@ -13,7 +13,6 @@ log() { printf '[upgrade] %s\n' "$*"; }
# =====================================================================
fix_duplicate_chrome_edge_repo() {
local edge="/etc/apt/sources.list.d/microsoft-edge.list"
local chrome="/etc/apt/sources.list.d/google-chrome.list"
if [[ ! -f $edge ]]; then
return
@ -57,7 +56,6 @@ fix_wine_legacy_key() {
local legacy_keyring="/etc/apt/trusted.gpg"
local wine_key_id="D43F640145369C51D786DDEA76F1A20FF987672F"
local modern_keyring="/usr/share/keyrings/winehq-archive.gpg"
local wine_source="/etc/apt/sources.list.d/winehq-focal.list"
# Check if wine key is in the legacy keyring
if ! gpg --no-default-keyring --keyring "$legacy_keyring" --list-keys "$wine_key_id" >/dev/null 2>&1; then

View File

@ -6,6 +6,7 @@ so source modules can be imported without moviepy installed.
from __future__ import annotations
import importlib
import sys
from typing import Any
from unittest.mock import MagicMock
@ -70,25 +71,38 @@ _clip_classes = [
"AudioArrayClip",
"CompositeAudioClip",
]
for _cls in _clip_classes:
getattr(mock_moviepy, _cls).side_effect = lambda *_a, **_kw: create_mock_clip()
mock_moviepy.concatenate_videoclips.side_effect = lambda *_a, **_kw: create_mock_clip()
mock_moviepy.concatenate_audioclips.side_effect = lambda *_a, **_kw: create_mock_clip()
mock_moviepy.video.compositing.CompositeVideoClip.clips_array.side_effect = (
lambda *_a, **_kw: create_mock_clip()
)
_drawing_shape = (_H, _W)
# Drawing tools must return real numpy arrays (used in numpy ops)
mock_moviepy.video.tools.drawing.circle.return_value = np.zeros(
(_H, _W), dtype=np.float64
)
mock_moviepy.video.tools.drawing.color_gradient.return_value = np.zeros(
(_H, _W), dtype=np.float64
)
mock_moviepy.video.tools.drawing.color_split.return_value = np.zeros(
(_H, _W), dtype=np.float64
)
def _reset_side_effects() -> None:
"""(Re)apply side_effect / return_value on the shared mock_moviepy."""
for cls_name in _clip_classes:
getattr(mock_moviepy, cls_name).side_effect = lambda *_a, **_kw: (
create_mock_clip()
)
mock_moviepy.concatenate_videoclips.side_effect = lambda *_a, **_kw: (
create_mock_clip()
)
mock_moviepy.concatenate_audioclips.side_effect = lambda *_a, **_kw: (
create_mock_clip()
)
mock_moviepy.video.compositing.CompositeVideoClip.clips_array.side_effect = (
lambda *_a, **_kw: create_mock_clip()
)
# Drawing tools must return real numpy arrays (used in numpy ops)
mock_moviepy.video.tools.drawing.circle.return_value = np.zeros(
_drawing_shape, dtype=np.float64
)
mock_moviepy.video.tools.drawing.color_gradient.return_value = np.zeros(
_drawing_shape, dtype=np.float64
)
mock_moviepy.video.tools.drawing.color_split.return_value = np.zeros(
_drawing_shape, dtype=np.float64
)
_reset_side_effects()
# ── Install into sys.modules ─────────────────────────────────────
_module_paths = [
@ -117,7 +131,21 @@ def _install_moviepy_mocks() -> None:
_install_moviepy_mocks()
_source_modules = [
"python_pkg.moviepy_showcase.moviepy_showcase",
"python_pkg.moviepy_showcase._moviepy_clip_types",
"python_pkg.moviepy_showcase._moviepy_video_effects",
"python_pkg.moviepy_showcase._moviepy_audio_output",
]
@pytest.fixture(autouse=True)
def _reinstall_moviepy_mocks() -> None:
"""Ensure our moviepy mocks are active even if another conftest overwrote."""
_install_moviepy_mocks()
_reset_side_effects()
# Re-bind cached ``from moviepy import X`` references in source modules
# that may point to stale MagicMock children from a prior sys.modules entry.
for mod_name in _source_modules:
if mod_name in sys.modules:
importlib.reload(sys.modules[mod_name])

View File

@ -4,10 +4,8 @@
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCREEN_LOCK_PATH="$SCRIPT_DIR/screen_lock.py"
SERVICE_FILE="$SCRIPT_DIR/workout-locker.service"
TIMER_FILE="$SCRIPT_DIR/workout-locker.timer"
USER_SERVICE_DIR="$HOME/.config/systemd/user"
SERVICE_NAME="workout-locker.service"
TIMER_NAME="workout-locker.timer"
# Check if service is already installed
if [ -f "$USER_SERVICE_DIR/$SERVICE_NAME" ]; then
@ -26,9 +24,14 @@ fi
# Create user systemd directory if it doesn't exist
mkdir -p "$USER_SERVICE_DIR"
# Copy service and timer files to user systemd directory
# Remove old timer if it was previously installed
if systemctl --user is-active "workout-locker.timer" &>/dev/null; then
systemctl --user disable --now "workout-locker.timer" 2>/dev/null || true
fi
rm -f "$USER_SERVICE_DIR/workout-locker.timer"
# Copy service file to user systemd directory
cp "$SERVICE_FILE" "$USER_SERVICE_DIR/$SERVICE_NAME"
cp "$TIMER_FILE" "$USER_SERVICE_DIR/$TIMER_NAME"
# Update paths in the service file to use absolute paths
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
@ -39,18 +42,16 @@ sed -i "s|ExecStart=/usr/bin/python3.*|ExecStart=/usr/bin/python3 -m python_pkg.
# Reload systemd daemon
systemctl --user daemon-reload
# Enable the service to start on login and the timer for periodic checks
# Enable the service to start on login (one-shot, no periodic timer)
systemctl --user enable "$SERVICE_NAME"
systemctl --user enable --now "$TIMER_NAME"
echo "✓ Workout locker service installed"
echo "✓ Service will start automatically on next login"
echo ""
echo "To start now: systemctl --user start workout-locker"
echo "To check status: systemctl --user status workout-locker"
echo "To check timer: systemctl --user list-timers workout-locker.timer"
echo "To stop: systemctl --user stop workout-locker"
echo "To disable autostart: systemctl --user disable workout-locker workout-locker.timer"
echo "To disable autostart: systemctl --user disable workout-locker"
# Check autostart installation status
echo ""

View File

@ -50,6 +50,21 @@ __all__ = [
_logger = logging.getLogger(__name__)
def _assert_not_under_pytest() -> None:
"""Raise if the screen locker is being created inside a pytest run.
Defence-in-depth: prevents a real fullscreen Tk window from locking
the user's screen when tests forget to mock ``tk.Tk``.
The check is cheap (one dict lookup) and only fires during testing.
"""
if "pytest" in sys.modules and getattr(tk, "__name__", "") == "tkinter":
msg = (
"SAFETY: ScreenLocker.__init__ called under pytest with "
"real tkinter — tk.Tk is not mocked"
)
raise RuntimeError(msg)
class ScreenLocker(
ShutdownMixin,
PhoneVerificationMixin,
@ -64,6 +79,7 @@ class ScreenLocker(
verify_only: bool = False,
) -> None:
"""Initialize screen locker with optional demo mode."""
_assert_not_under_pytest()
script_dir = Path(__file__).resolve().parent
self.log_file = script_dir / "workout_log.json"
self.verify_only = verify_only

View File

@ -1,4 +1,12 @@
"""Shared fixtures and helpers for screen_locker tests."""
"""Shared fixtures and helpers for screen_locker tests.
Safety:
``_block_real_tk_and_exit`` (autouse) replaces the **entire** ``tk``
module reference inside ``screen_lock`` with a MagicMock and stubs
``sys.exit``. This makes it physically impossible for any test to
create a real Tk root window, go fullscreen, or grab input even if
the test forgets to request the explicit ``mock_tk`` fixture.
"""
from __future__ import annotations
@ -12,7 +20,40 @@ import pytest
from python_pkg.screen_locker.screen_lock import ScreenLocker
if TYPE_CHECKING:
from collections.abc import Generator
from collections.abc import Generator, Iterator
def _make_mock_tk() -> MagicMock:
"""Build a MagicMock that stands in for the ``tkinter`` module."""
mock = MagicMock()
mock_root = MagicMock()
mock_root.winfo_screenwidth.return_value = 1920
mock_root.winfo_screenheight.return_value = 1080
mock.Tk.return_value = mock_root
mock_frame = MagicMock()
mock_frame.winfo_children.return_value = []
mock.Frame.return_value = mock_frame
# Keep real TclError so ``except tk.TclError`` still works.
mock.TclError = tk.TclError
return mock
@pytest.fixture(autouse=True)
def _block_real_tk_and_exit() -> Iterator[None]:
"""Replace the whole ``tk`` module and ``sys.exit`` for every test.
Patching the entire module (not just ``tk.Tk``) ensures that
**nothing** in tkinter can touch the real display server.
"""
mock = _make_mock_tk()
with (
patch("python_pkg.screen_locker.screen_lock.tk", mock),
patch("python_pkg.screen_locker.screen_lock.sys.exit"),
):
yield
@pytest.fixture

View File

@ -10,12 +10,29 @@ from unittest.mock import MagicMock, patch
import pytest
from python_pkg.screen_locker.screen_lock import _assert_not_under_pytest
from python_pkg.screen_locker.tests.conftest import create_locker
if TYPE_CHECKING:
from pathlib import Path
class TestAssertNotUnderPytest:
"""Tests for the _assert_not_under_pytest runtime guard."""
def test_raises_when_tk_is_real(self) -> None:
"""Guard fires if tk.Tk is the real tkinter class under pytest."""
with (
patch("python_pkg.screen_locker.screen_lock.tk", tk),
pytest.raises(RuntimeError, match="SAFETY"),
):
_assert_not_under_pytest()
def test_silent_when_tk_is_mocked(self) -> None:
"""Guard stays silent when tk is already mocked (normal test run)."""
_assert_not_under_pytest()
class TestScreenLockerInit:
"""Tests for ScreenLocker initialization."""

View File

@ -1,10 +0,0 @@
[Unit]
Description=Periodically check if workout was done today
[Timer]
OnBootSec=5s
OnUnitActiveSec=15min
Persistent=true
[Install]
WantedBy=timers.target

View File

@ -15,6 +15,29 @@ import time
logger = logging.getLogger(__name__)
# Real Steam directory — used as a safety check to block destructive
# operations that leak through during testing.
_REAL_STEAMAPPS = Path("~/.local/share/Steam/steamapps").expanduser()
def _assert_not_real_steam(path: Path) -> None:
"""Raise if *path* is inside the real Steam directory.
Defence-in-depth guard: even if test fixtures fail to
redirect ``STEAMAPPS_PATH``, destructive operations
(uninstall, rmtree, unlink) will refuse to touch real files.
"""
try:
path.resolve().relative_to(_REAL_STEAMAPPS.resolve())
except ValueError:
return # path is NOT under real Steam — safe to proceed
if STEAMAPPS_PATH.resolve() == _REAL_STEAMAPPS.resolve():
msg = (
f"SAFETY: refusing destructive operation on real Steam path "
f"{path!s} — STEAMAPPS_PATH was not redirected by test fixtures"
)
raise RuntimeError(msg)
def _echo(msg: str = "", *, end: str = "\n", flush: bool = False) -> None:
"""Write user-facing CLI output to stdout.
@ -56,6 +79,7 @@ PROTECTED_APP_IDS = {
1007020, # Proton EasyAntiCheat Runtime
# Games allowed to be installed anytime
3949040, # RV There Yet?
2252570,
}
STEAMAPPS_PATH = Path("~/.local/share/Steam/steamapps").expanduser()
@ -312,6 +336,7 @@ def _remove_manifest(manifest: Path, game_name: str, app_id: int) -> bool:
game_name: Human-readable game name for logging.
app_id: Steam application ID.
"""
_assert_not_real_steam(manifest)
try:
if manifest.exists():
manifest.unlink()
@ -333,6 +358,7 @@ def _remove_game_dirs(install_dir: Path | None, app_id: int) -> bool:
"""
success = True
if install_dir and install_dir.is_dir():
_assert_not_real_steam(install_dir)
try:
shutil.rmtree(install_dir)
logger.info("Removed game files: %s", install_dir)
@ -343,6 +369,7 @@ def _remove_game_dirs(install_dir: Path | None, app_id: int) -> bool:
for subdir in ("shadercache", "compatdata"):
cache_path = STEAMAPPS_PATH / subdir / str(app_id)
if cache_path.is_dir():
_assert_not_real_steam(cache_path)
with contextlib.suppress(OSError):
shutil.rmtree(cache_path)
logger.debug("Removed %s/%d", subdir, app_id)

View File

@ -154,6 +154,10 @@ def _pick_best_hltb_entry(
When a short name like "FAITH" matches both "FAITH" (demo) and
"FAITH: The Unholy Trinity" (full game), prefer the full game
since Steam often lists the full game under the shorter name.
When an exact match like "Timberman" (26 h) competes against an
unrelated subtitle entry like "Timberman: The Big Adventure" (2 h),
the exact match wins because it has more hours.
"""
if not candidates:
return None
@ -165,36 +169,72 @@ def _pick_best_hltb_entry(
return usable[0]
lower = search_name.lower()
best_exact = _find_exact_match(usable, lower)
best_extended = _find_best_extended(usable, lower)
return _resolve_exact_vs_extended(best_exact, best_extended, usable)
def _find_exact_match(
usable: list[tuple[dict[str, Any], float]],
lower: str,
) -> tuple[dict[str, Any], float] | None:
"""Find best exact name/alias match (highest comp_100)."""
return next(
(
(e, s)
for e, s in sorted(
usable,
key=lambda x: x[0].get("comp_100", 0),
reverse=True,
)
if (e.get("game_name") or "").lower() == lower
or (e.get("game_alias") or "").lower() == lower
),
None,
)
def _find_best_extended(
usable: list[tuple[dict[str, Any], float]],
lower: str,
) -> tuple[dict[str, Any], float] | None:
"""Find best extended entry ("Name: Subtitle" / "Name - Subtitle").
Skips subset entries (prologue, demo, etc.).
"""
best: tuple[dict[str, Any], float] | None = None
for entry, sim in usable:
entry_name = (entry.get("game_name") or "").lower()
if entry_name.startswith((lower + ":", lower + " -")):
suffix = entry_name[len(lower) :].lstrip(" :-")
if not any(suffix.startswith(kw) for kw in _SUBSET_SUFFIXES):
# Only prefer this extended entry when it has strictly more
# comp_100 than any exact-name match. This prevents
# "Killing Floor: Toy Master" (1.2 h) from beating
# "Killing Floor" (296 h) while still letting
# "FAITH: The Unholy Trinity" (7 h) beat "FAITH" (0.5 h demo).
extended_hours = entry.get("comp_100", 0)
best_exact = next(
(
(e, s)
for e, s in sorted(
usable,
key=lambda x: x[0].get("comp_100", 0),
reverse=True,
)
if (e.get("game_name") or "").lower() == lower
or (e.get("game_alias") or "").lower() == lower
),
None,
)
if (
best_exact is not None
and best_exact[0].get("comp_100", 0) >= extended_hours
):
return best_exact
return entry, sim
if not any(suffix.startswith(kw) for kw in _SUBSET_SUFFIXES) and (
best is None or entry.get("comp_100", 0) > best[0].get("comp_100", 0)
):
best = (entry, sim)
return best
def _resolve_exact_vs_extended(
best_exact: tuple[dict[str, Any], float] | None,
best_extended: tuple[dict[str, Any], float] | None,
usable: list[tuple[dict[str, Any], float]],
) -> tuple[dict[str, Any], float]:
"""Decide between exact match, extended entry, or highest similarity."""
if best_exact is not None and best_extended is not None:
exact_hours = best_exact[0].get("comp_100", 0)
extended_hours = best_extended[0].get("comp_100", 0)
# Prefer the extended entry only when it has strictly more hours
# than the exact match. This lets "FAITH: The Unholy Trinity"
# (7 h) beat "FAITH" (0.5 h demo) while preventing
# "Timberman: The Big Adventure" (2 h) from beating
# "Timberman" (26 h).
if extended_hours > exact_hours:
return best_extended
return best_exact
if best_exact is not None:
return best_exact
if best_extended is not None:
return best_extended
# Fall back to highest similarity.
return max(usable, key=lambda x: x[1])

View File

@ -7,12 +7,14 @@ to temporary directories. This stops tests from accidentally:
user's current assignment)
- Reading real appmanifest files from ~/.local/share/Steam/steamapps
- Modifying /etc/hosts via the store blocker
- Corrupting the HLTB cache on disk
- Launching real Steam or calling real subprocess commands
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import patch
from unittest.mock import MagicMock, patch
import pytest
@ -57,6 +59,12 @@ def _isolate_filesystem(tmp_path: Path) -> Iterator[None]:
"python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH",
fake_steamapps,
),
# HLTB cache file (computed at import time from CONFIG_DIR, so
# patching CONFIG_DIR alone does not redirect it)
patch(
"python_pkg.steam_backlog_enforcer._hltb_types.HLTB_CACHE_FILE",
fake_config / "hltb_cache.json",
),
# /etc/hosts (store blocker)
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.HOSTS_FILE",
@ -68,3 +76,43 @@ def _isolate_filesystem(tmp_path: Path) -> Iterator[None]:
),
):
yield
@pytest.fixture(autouse=True)
def _block_real_subprocesses() -> Iterator[None]:
"""Block subprocess calls that could launch real Steam or modify system.
Individual tests that need to test subprocess behaviour should
patch the specific module's ``subprocess.run`` / ``subprocess.Popen``
themselves their local patch will override this one.
"""
noop_run = MagicMock(return_value=MagicMock(returncode=1))
noop_popen = MagicMock()
with (
patch(
"python_pkg.steam_backlog_enforcer.game_install.subprocess.run",
noop_run,
),
patch(
"python_pkg.steam_backlog_enforcer.game_install.subprocess.Popen",
noop_popen,
),
patch(
"python_pkg.steam_backlog_enforcer.enforcer.subprocess.run",
noop_run,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run",
noop_run,
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider.subprocess.run",
noop_run,
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider.subprocess.Popen",
noop_popen,
),
):
yield

View File

@ -6,7 +6,10 @@ import os
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
import pytest
from python_pkg.steam_backlog_enforcer.game_install import (
_assert_not_real_steam,
_echo,
_ensure_steam_running,
_get_real_user,
@ -22,7 +25,43 @@ from python_pkg.steam_backlog_enforcer.game_install import (
if TYPE_CHECKING:
from pathlib import Path
import pytest
PKG = "python_pkg.steam_backlog_enforcer.game_install"
class TestAssertNotRealSteam:
"""Tests for the _assert_not_real_steam safety guard."""
def test_allows_tmp_path(self, tmp_path: Path) -> None:
"""Non-Steam paths pass through without raising."""
_assert_not_real_steam(tmp_path / "appmanifest_440.acf")
def test_raises_when_real_steam_not_redirected(self, tmp_path: Path) -> None:
"""Raises when path is under real Steam and STEAMAPPS_PATH is real."""
real = tmp_path / "real_steam"
real.mkdir()
fake_manifest = real / "appmanifest_440.acf"
fake_manifest.touch()
with (
patch(f"{PKG}._REAL_STEAMAPPS", real),
patch(f"{PKG}.STEAMAPPS_PATH", real),
pytest.raises(RuntimeError, match="SAFETY"),
):
_assert_not_real_steam(fake_manifest)
def test_allows_when_steamapps_redirected(self, tmp_path: Path) -> None:
"""No raise when STEAMAPPS_PATH differs from _REAL_STEAMAPPS."""
real = tmp_path / "real_steam"
real.mkdir()
fake_manifest = real / "appmanifest_440.acf"
fake_manifest.touch()
redirected = tmp_path / "fake_steam"
redirected.mkdir()
with (
patch(f"{PKG}._REAL_STEAMAPPS", real),
patch(f"{PKG}.STEAMAPPS_PATH", redirected),
):
_assert_not_real_steam(fake_manifest)
class TestEcho:

View File

@ -345,3 +345,89 @@ class TestPickBestHltbEntry:
)
assert result is not None
assert result[0]["game_name"] == "NEEDY GIRL OVERDOSE"
def test_exact_match_beats_different_subtitled_game(self) -> None:
"""Exact 'Timberman' (26.5 h) must beat 'Timberman: The Big Adventure' (2 h).
Unlike FAITH where the short name is a demo, here the short name
is the real full game and the subtitled entry is a different, shorter
game. The exact match should win because it has more hours.
"""
base: dict[str, Any] = {
"game_name": "Timberman",
"comp_100": 95400, # 26.5 h
}
other: dict[str, Any] = {
"game_name": "Timberman: The Big Adventure",
"comp_100": 7200, # 2 h
}
timberman_vs: dict[str, Any] = {
"game_name": "Timberman VS",
"comp_100": 23400, # 6.5 h
}
result = _pick_best_hltb_entry(
"Timberman",
[(other, 0.49), (timberman_vs, 0.86), (base, 1.0)],
)
assert result is not None
assert result[0]["game_name"] == "Timberman"
def test_exact_match_wins_even_when_extended_appears_first(self) -> None:
"""Exact match wins regardless of candidate ordering."""
base: dict[str, Any] = {
"game_name": "Timberman",
"comp_100": 95400, # 26.5 h
}
other: dict[str, Any] = {
"game_name": "Timberman: The Big Adventure",
"comp_100": 7200, # 2 h
}
# Extended entry appears first in the list.
result = _pick_best_hltb_entry(
"Timberman",
[(other, 0.49), (base, 1.0)],
)
assert result is not None
assert result[0]["game_name"] == "Timberman"
def test_exact_only_no_extended(self) -> None:
"""Exact match returned when no extended entries exist at all."""
exact: dict[str, Any] = {
"game_name": "Celeste",
"comp_100": 180000, # 50 h
}
unrelated: dict[str, Any] = {
"game_name": "Unrelated Game",
"comp_100": 7200,
}
result = _pick_best_hltb_entry(
"Celeste",
[(exact, 1.0), (unrelated, 0.6)],
)
assert result is not None
assert result[0]["game_name"] == "Celeste"
def test_no_exact_no_extended_falls_back(self) -> None:
"""When no exact or extended match exists, fall to highest similarity."""
a: dict[str, Any] = {"game_name": "FooBar", "comp_100": 3600}
b: dict[str, Any] = {"game_name": "FooBaz", "comp_100": 7200}
result = _pick_best_hltb_entry("Foo", [(a, 0.7), (b, 0.8)])
assert result is not None
assert result[0]["game_name"] == "FooBaz"
def test_extended_only_no_exact(self) -> None:
"""Extended entry returned when no exact name match exists."""
extended: dict[str, Any] = {
"game_name": "Neon: Ultimate Edition",
"comp_100": 36000,
}
unrelated: dict[str, Any] = {
"game_name": "Neon Lights",
"comp_100": 3600,
}
result = _pick_best_hltb_entry(
"Neon",
[(extended, 0.6), (unrelated, 0.7)],
)
assert result is not None
assert result[0]["game_name"] == "Neon: Ultimate Edition"

View File

@ -20,8 +20,6 @@ from python_pkg.steam_backlog_enforcer.library_hider import (
_wait_for_cdp_ready,
_wait_for_collections_ready,
ensure_steam_debug_port,
hide_other_games,
unhide_all_games,
)
@ -425,85 +423,3 @@ class TestEnsureSteamDebugPort:
),
):
ensure_steam_debug_port()
class TestHideOtherGames:
"""Tests for hide_other_games."""
def test_hides(self) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.library_hider.ensure_steam_debug_port",
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._evaluate_js",
return_value={
"result": {"result": {"value": '{"totalHidden": 5}'}},
},
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._cdp_result_value",
return_value='{"totalHidden": 5}',
),
):
count = hide_other_games([1, 2, 3], 1)
assert count == 5
def test_empty_list(self) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.library_hider.ensure_steam_debug_port",
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._evaluate_js",
return_value={
"result": {"result": {"value": '{"totalHidden": 0}'}},
},
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._cdp_result_value",
return_value='{"totalHidden": 0}',
),
):
count = hide_other_games([1], 1)
assert count == 0
def test_no_allowed(self) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.library_hider.ensure_steam_debug_port",
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._evaluate_js",
return_value={
"result": {"result": {"value": '{"totalHidden": 2}'}},
},
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._cdp_result_value",
return_value='{"totalHidden": 2}',
),
):
count = hide_other_games([1, 2], None)
assert count == 2
class TestUnhideAllGames:
"""Tests for unhide_all_games."""
def test_unhides(self) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.library_hider.ensure_steam_debug_port",
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._evaluate_js",
return_value={"result": {"result": {"value": '{"count": 10}'}}},
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._cdp_result_value",
return_value='{"count": 10}',
),
):
count = unhide_all_games([1, 2, 3])
assert count == 10

View File

@ -8,7 +8,9 @@ from unittest.mock import MagicMock, patch
from python_pkg.steam_backlog_enforcer.library_hider import (
_run_as_user,
hide_other_games,
restart_steam,
unhide_all_games,
)
PKG = "python_pkg.steam_backlog_enforcer.library_hider"
@ -116,3 +118,77 @@ class TestRestartSteam:
patch(f"{PKG}._wait_for_cdp_ready", return_value=False),
):
restart_steam()
class TestHideOtherGames:
"""Tests for hide_other_games."""
def test_hides(self) -> None:
with (
patch(f"{PKG}.ensure_steam_debug_port"),
patch(
f"{PKG}._evaluate_js",
return_value={
"result": {"result": {"value": '{"totalHidden": 5}'}},
},
),
patch(
f"{PKG}._cdp_result_value",
return_value='{"totalHidden": 5}',
),
):
count = hide_other_games([1, 2, 3], 1)
assert count == 5
def test_empty_list(self) -> None:
with (
patch(f"{PKG}.ensure_steam_debug_port"),
patch(
f"{PKG}._evaluate_js",
return_value={
"result": {"result": {"value": '{"totalHidden": 0}'}},
},
),
patch(
f"{PKG}._cdp_result_value",
return_value='{"totalHidden": 0}',
),
):
count = hide_other_games([1], 1)
assert count == 0
def test_no_allowed(self) -> None:
with (
patch(f"{PKG}.ensure_steam_debug_port"),
patch(
f"{PKG}._evaluate_js",
return_value={
"result": {"result": {"value": '{"totalHidden": 2}'}},
},
),
patch(
f"{PKG}._cdp_result_value",
return_value='{"totalHidden": 2}',
),
):
count = hide_other_games([1, 2], None)
assert count == 2
class TestUnhideAllGames:
"""Tests for unhide_all_games."""
def test_unhides(self) -> None:
with (
patch(f"{PKG}.ensure_steam_debug_port"),
patch(
f"{PKG}._evaluate_js",
return_value={"result": {"result": {"value": '{"count": 10}'}}},
),
patch(
f"{PKG}._cdp_result_value",
return_value='{"count": 10}',
),
):
count = unhide_all_games([1, 2, 3])
assert count == 10

View File

@ -325,3 +325,25 @@ class TestArgosImportReload:
importlib.reload(_helpers)
# Restore original module state
importlib.reload(_helpers)
class TestTorchImportReload:
"""Test import-time torch ImportError branch via reload."""
def test_torch_import_failure_reload(self) -> None:
"""Cover lines 19-20 (except ImportError: torch = None) via reload."""
import builtins
original_import = builtins.__import__
def _block_torch(name: str, *args: object, **kwargs: object) -> object:
if name == "torch":
msg = "mocked torch unavailable"
raise ImportError(msg)
return original_import(name, *args, **kwargs)
with patch.object(builtins, "__import__", side_effect=_block_torch):
importlib.reload(_helpers)
assert _helpers.torch is None
# Restore original module state
importlib.reload(_helpers)

View File

@ -1,8 +1,10 @@
aiohttp>=3.9
beautifulsoup4>=4.0
berserk>=0.13
bottle>=0.12
genanki>=0.13
geopandas>=1.0
howlongtobeatpy>=1.0
lxml>=5.0
# Optional dependencies for specific scripts (needed for full pylint analysis)

View File

@ -59,6 +59,8 @@ is_allowed() {
found=0
for file in "$@"; do
# Skip files not tracked by git (deleted from index or untracked)
git ls-files --error-unmatch "$file" >/dev/null 2>&1 || continue
# Check if the file matches a blocked extension
if echo "$file" | grep -qiE "$pattern"; then
if is_allowed "$file"; then

330
scripts/disk_cleanup_check.sh Executable file
View File

@ -0,0 +1,330 @@
#!/usr/bin/env bash
# disk_cleanup_check.sh — Analyze disk usage and suggest (or perform) cleanup.
#
# Usage:
# ./disk_cleanup_check.sh # Dry-run: report only
# ./disk_cleanup_check.sh --clean # Interactive: prompt before each action
#
# Safe by default: nothing is deleted without --clean AND user confirmation.
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
BOLD='\033[1m'
RESET='\033[0m'
CLEAN=false
if [[ "${1:-}" == "--clean" ]]; then
CLEAN=true
fi
TOTAL_RECLAIMABLE=0
# ───────────────────── helpers ─────────────────────
human_readable() {
local kb=$1
if (( kb >= 1048576 )); then
printf "%.1f GB" "$(echo "scale=1; $kb / 1048576" | bc)"
elif (( kb >= 1024 )); then
printf "%.1f MB" "$(echo "scale=1; $kb / 1024" | bc)"
else
printf "%d KB" "$kb"
fi
}
dir_size_kb() {
local dir="$1"
if [[ -d "$dir" ]]; then
du -sk "$dir" 2>/dev/null | awk '{print $1}'
else
echo 0
fi
}
report() {
local label="$1" size_kb="$2" detail="${3:-}"
if (( size_kb > 0 )); then
local hr
hr=$(human_readable "$size_kb")
printf "${YELLOW}%-40s${RESET} %10s" "$label" "$hr"
if [[ -n "$detail" ]]; then
printf " ${CYAN}(%s)${RESET}" "$detail"
fi
printf "\n"
TOTAL_RECLAIMABLE=$(( TOTAL_RECLAIMABLE + size_kb ))
fi
}
# Returns 0 if user confirms, 1 otherwise. Always 1 in dry-run.
confirm() {
local prompt="$1"
if $CLEAN; then
printf "${BOLD} → %s [y/N]: ${RESET}" "$prompt"
read -r ans
if [[ "$ans" =~ ^[Yy]$ ]]; then
return 0
fi
fi
return 1
}
# Safe wrapper: confirm + action, never fails under errexit
try_clean() {
local prompt="$1"
shift
if confirm "$prompt"; then
"$@"
printf "${GREEN} ✓ Done${RESET}\n"
fi
return 0
}
# ───────────────────── checks ─────────────────────
printf "\n${BOLD}=== Disk Cleanup Analysis ===${RESET}\n\n"
printf "${BOLD}%-40s %10s${RESET}\n" "Category" "Reclaimable"
printf '%s\n' "$(printf '%.0s─' {1..60})"
# 1. Trash
trash_dir="$HOME/.local/share/Trash"
size=$(dir_size_kb "$trash_dir")
report "Trash" "$size" "empty trash"
if (( size > 0 )); then
try_clean "Empty trash?" \
rm -rf "${trash_dir}/files" "${trash_dir}/info" "${trash_dir}/expunged"
fi
# 2. Pacman cache
pacman_cache_size=$(dir_size_kb "/var/cache/pacman/pkg")
if (( pacman_cache_size > 0 )); then
if command -v paccache &>/dev/null; then
pkg_count=$(ls /var/cache/pacman/pkg/ 2>/dev/null | wc -l)
report "Pacman cache ($pkg_count pkgs)" "$pacman_cache_size" \
"sudo paccache -rk2"
try_clean "Clean pacman cache (keep last 2 versions)?" \
sudo paccache -rk2
else
report "Pacman cache (install pacman-contrib)" "$pacman_cache_size" \
"pacman -S pacman-contrib && paccache -rk2"
fi
fi
# 3. Yay build cache
yay_cache="$HOME/.cache/yay"
size=$(dir_size_kb "$yay_cache")
report "Yay AUR build cache" "$size" "rm -rf ~/.cache/yay"
if (( size > 0 )); then
try_clean "Clear yay build cache?" rm -rf "${yay_cache:?}"
fi
# 4. Pip cache
pip_cache="$HOME/.cache/pip"
size=$(dir_size_kb "$pip_cache")
report "Pip cache" "$size" "pip cache purge"
if (( size > 0 )); then
try_clean "Clear pip cache?" rm -rf "${pip_cache:?}"
fi
# 5. uv cache
uv_cache="$HOME/.cache/uv"
size=$(dir_size_kb "$uv_cache")
report "uv cache" "$size" "uv cache clean"
if (( size > 0 )); then
try_clean "Clear uv cache?" rm -rf "${uv_cache:?}"
fi
# 6. HuggingFace cache
hf_cache="$HOME/.cache/huggingface"
size=$(dir_size_kb "$hf_cache")
report "HuggingFace models" "$size" "manual review recommended"
# 7. Suno cache
suno_cache="$HOME/.cache/suno"
size=$(dir_size_kb "$suno_cache")
report "Suno AI cache" "$size" "manual review recommended"
# 8. Go build cache
go_cache="$HOME/.cache/go-build"
size=$(dir_size_kb "$go_cache")
report "Go build cache" "$size" "go clean -cache"
if (( size > 0 )); then
try_clean "Clear Go build cache?" rm -rf "${go_cache:?}"
fi
# 9. Yarn cache
yarn_cache="$HOME/.cache/yarn"
size=$(dir_size_kb "$yarn_cache")
report "Yarn cache" "$size" "yarn cache clean"
if (( size > 0 )); then
try_clean "Clear yarn cache?" rm -rf "${yarn_cache:?}"
fi
# 10. npm cache
npm_cache="$HOME/.npm/_cacache"
size=$(dir_size_kb "$npm_cache")
report "npm cache" "$size" "npm cache clean --force"
if (( size > 0 )); then
try_clean "Clear npm cache?" rm -rf "${npm_cache:?}"
fi
# 11. Electron cache
electron_cache="$HOME/.cache/electron"
size=$(dir_size_kb "$electron_cache")
report "Electron cache" "$size"
if (( size > 0 )); then
try_clean "Clear Electron cache?" rm -rf "${electron_cache:?}"
fi
# 12. Thumbnails
thumb_cache="$HOME/.cache/thumbnails"
size=$(dir_size_kb "$thumb_cache")
report "Thumbnails cache" "$size"
if (( size > 0 )); then
try_clean "Clear thumbnails?" rm -rf "${thumb_cache:?}"
fi
# 13. vscode-cpptools cache
cpptools_cache="$HOME/.cache/vscode-cpptools"
size=$(dir_size_kb "$cpptools_cache")
if (( size > 102400 )); then
report "VS Code cpptools cache" "$size"
try_clean "Clear cpptools cache?" rm -rf "${cpptools_cache:?}"
fi
# 14. Bazel cache
bazel_cache="$HOME/.cache/bazel"
size=$(dir_size_kb "$bazel_cache")
report "Bazel cache" "$size"
if (( size > 0 )); then
try_clean "Clear bazel cache?" rm -rf "${bazel_cache:?}"
fi
# 15. Browser caches (report only)
for browser_cache in \
"$HOME/.cache/thorium" \
"$HOME/.cache/google-chrome" \
"$HOME/.cache/mozilla" \
"$HOME/.cache/BraveSoftware"; do
if [[ -d "$browser_cache" ]]; then
name=$(basename "$browser_cache")
size=$(dir_size_kb "$browser_cache")
if (( size > 102400 )); then
report "Browser cache: $name" "$size" "close browser first"
fi
fi
done
# 16. Docker
if command -v docker &>/dev/null; then
docker_dir="/var/lib/docker"
size=$(dir_size_kb "$docker_dir" || echo 0)
if (( size > 1048576 )); then
report "Docker (images+build cache)" "$size" "docker system prune -a"
try_clean "Docker system prune (removes all unused)?" \
docker system prune -af --volumes
fi
fi
# 17. Orphan packages
orphans=$(pacman -Qdtq 2>/dev/null || true)
orphan_count=0
if [[ -n "$orphans" ]]; then
orphan_count=$(echo "$orphans" | wc -l)
fi
if (( orphan_count > 0 )); then
orphan_size=$(echo "$orphans" | xargs pacman -Qi 2>/dev/null \
| awk '/Installed Size/{
size=$4; unit=$5;
if (unit ~ /GiB/) total += size*1048576;
else if (unit ~ /MiB/) total += size*1024;
else if (unit ~ /KiB/) total += size;
} END{printf "%d", total}' || echo 0)
report "Orphan packages ($orphan_count)" "$orphan_size" \
"sudo pacman -Rns \$(pacman -Qdtq)"
if confirm "Remove $orphan_count orphan packages?"; then
# shellcheck disable=SC2046
sudo pacman -Rns $(pacman -Qdtq) --noconfirm
printf "${GREEN} ✓ Orphans removed${RESET}\n"
fi
fi
# 18. Journal logs — vacuum to 200M
journal_line=$(journalctl --disk-usage 2>&1 || true)
journal_kb=0
if [[ "$journal_line" =~ ([0-9.]+)G ]]; then
journal_kb=$(echo "${BASH_REMATCH[1]} * 1048576 / 1" | bc)
elif [[ "$journal_line" =~ ([0-9.]+)M ]]; then
journal_kb=$(echo "${BASH_REMATCH[1]} * 1024 / 1" | bc)
fi
if (( journal_kb > 204800 )); then
excess=$(( journal_kb - 204800 ))
report "Journal logs (excess over 200M)" "$excess" "journalctl --vacuum-size=200M"
try_clean "Vacuum journal to 200M?" \
sudo journalctl --vacuum-size=200M
fi
# 19. AUR source dirs — old archives (>30 days)
aur_dir="$HOME/aur"
if [[ -d "$aur_dir" ]]; then
old_archives_kb=$(find "$aur_dir" \
\( -name "*.pkg.tar.zst" -o -name "*.tar.gz" -o -name "*.tar.xz" \
-o -name "*.zip" -o -name "*.tar.bz2" \) \
-mtime +30 -printf '%k\n' 2>/dev/null \
| awk '{t+=$1} END{print t+0}')
if (( old_archives_kb > 0 )); then
report "AUR old archives (>30d)" "$old_archives_kb" \
"find ~/aur '*.pkg.tar.zst' -mtime +30 -delete"
if confirm "Delete old AUR archives (>30 days)?"; then
find "$aur_dir" \
\( -name "*.pkg.tar.zst" -o -name "*.tar.gz" -o -name "*.tar.xz" \
-o -name "*.zip" -o -name "*.tar.bz2" \) \
-mtime +30 -delete
printf "${GREEN} ✓ Old AUR archives deleted${RESET}\n"
fi
fi
fi
# ───────────────────── report-only items ─────────────────────
printf "\n${BOLD}--- Report-only (manual review needed) ---${RESET}\n"
for manual_entry in \
"$HOME/Downloads/too_big:~/Downloads/too_big" \
"$HOME/Downloads:~/Downloads total" \
"$HOME/inne:~/inne" \
"/Games:/Games — still playing?"; do
dir="${manual_entry%%:*}"
label="${manual_entry#*:}"
size=$(dir_size_kb "$dir")
if (( size > 0 )); then
hr=$(human_readable "$size")
printf "${RED}%-40s %10s${RESET} ${CYAN}(review manually)${RESET}\n" \
"$label" "$hr"
fi
done
# ───────────────────── summary ─────────────────────
printf "\n%s\n" "$(printf '%.0s─' {1..60})"
total_hr=$(human_readable "$TOTAL_RECLAIMABLE")
printf "${BOLD}Total auto-reclaimable: ${GREEN}%s${RESET}\n" "$total_hr"
read -r used_kb total_kb used_pct <<< "$(df -k / | awk 'NR==2{print $3, $2, $5}')"
used_pct="${used_pct%\%}"
printf "${BOLD}Current usage: %s / %s (%s%%)${RESET}\n" \
"$(human_readable "$used_kb")" "$(human_readable "$total_kb")" "$used_pct"
if (( TOTAL_RECLAIMABLE > 0 )); then
new_used=$(( used_kb - TOTAL_RECLAIMABLE ))
if (( new_used < 0 )); then new_used=0; fi
new_pct=$(( new_used * 100 / total_kb ))
printf "${BOLD}After cleanup: %s / %s (%d%%)${RESET}\n" \
"$(human_readable "$new_used")" "$(human_readable "$total_kb")" "$new_pct"
fi
if ! $CLEAN; then
printf "\n${CYAN}Run with --clean to interactively clean each category.${RESET}\n"
fi
printf "\n"

Binary file not shown.

Binary file not shown.