diff --git a/README.md b/README.md index a7265f8..98f7934 100644 --- a/README.md +++ b/README.md @@ -100,28 +100,67 @@ powershell -ExecutionPolicy Bypass -File .\scripts\reinstall.ps1 Or from the tray menu: **Reinstall**. -### Running without installing +### Run from source (no install required) ```powershell # Activate the venv first .venv\Scripts\Activate.ps1 -# Then run directly +# Start the tray python -m selfsnap tray + +# Take a manual capture python -m selfsnap capture --trigger manual + +# Run a scheduled capture for a specific schedule +python -m selfsnap capture --trigger scheduled --schedule-id morning + +# Sync Windows Task Scheduler tasks from config +python -m selfsnap sync-scheduler + +# Catch up missed schedule slots (run on startup to reconcile gaps) +python -m selfsnap reconcile + +# Run diagnostics +python -m selfsnap doctor +python -m selfsnap diag + +# Print the installed version +python -m selfsnap --version ``` -### Start the tray (after install) +### Run after install + +Once `install.ps1` has been run, the `SelfSnap` wrapper is on your `PATH`: ```powershell +# Start the tray SelfSnap tray -``` -### Run diagnostics +# Take a manual capture +SelfSnap capture --trigger manual -```powershell +# Run a scheduled capture +SelfSnap capture --trigger scheduled --schedule-id morning + +# Sync Task Scheduler +SelfSnap sync-scheduler + +# Reconcile missed slots +SelfSnap reconcile + +# Diagnostics SelfSnap doctor SelfSnap diag + +# Check for updates without installing +SelfSnap update --check-only + +# Reinstall from current checkout +SelfSnap reinstall + +# Uninstall (prompts for confirmation) +SelfSnap uninstall ``` ## Runtime storage @@ -206,33 +245,6 @@ If you are using the current source checkout rather than reading historical desi - `docs/CONFIG_REFERENCE.md` - `docs/WORKFLOWS.md` -## Commands - -```powershell -selfsnap tray -selfsnap capture --trigger manual -selfsnap capture --trigger scheduled --schedule-id morning -selfsnap reconcile -selfsnap sync-scheduler -selfsnap reinstall [--relaunch-tray] -selfsnap uninstall [--remove-user-data] [--yes] -selfsnap update [--check-only] [--relaunch-tray] -selfsnap diag -selfsnap doctor -``` - -`reinstall`, `uninstall`, and `update` mirror the tray menu actions but are usable from any terminal — useful when the tray is not running or for scripted management. - -- `selfsnap update --check-only` prints whether a newer release is available without installing anything. -- `selfsnap uninstall` prompts `Uninstall SelfSnap? [y/N]` unless `--yes` is passed. - -If `scripts/install.ps1` has been run, the wrapper command is: - -```powershell -$env:LOCALAPPDATA\SelfSnap\bin\SelfSnap.cmd tray -$env:LOCALAPPDATA\SelfSnap\bin\SelfSnap.cmd capture --trigger manual -``` - ## Report Issue - `Report Issue` is available from the tray menu and is the default tray action where the Windows shell supports pystray default activation. @@ -353,17 +365,30 @@ mypy src Pytest temp directories are intentionally kept out of the repo under `%LOCALAPPDATA%\SelfSnap\pytest\tmp`, and the pytest cache provider is disabled to avoid `.pytest_cache` and `pytest-cache-files-*` clutter in the workspace. -If `.pytest_tmp` or `.pytest-work` still exist in the repo root with old timestamps, treat them as legacy leftovers from earlier runs. Current pytest configuration should not recreate them. +Use `scripts/cleanup_repo_artifacts.ps1` to remove all local artifact and cache folders. It covers: -If stale pytest artifact folders remain from older runs and normal deletion fails, use the dedicated cleanup script: +- Coverage data: `cov_annotate/`, `.coverage`, `coverage.xml` +- Bytecode caches: `__pycache__/` (recursive), `*.pyc` / `*.pyo` / `*.pyd` +- Test caches: `.pytest_cache/`, `.pytest_tmp/`, `.pytest-work/`, `pytest-cache-files-*/` +- Tool caches: `.ruff_cache/`, `.mypy_cache/`, `.hypothesis/`, `.uv-cache/` +- Build outputs: `build/`, `dist/`, `*.egg-info/` +- Any other git-ignored or untracked file + +`.venv/` and `.git/` are always protected. ```powershell -powershell -ExecutionPolicy Bypass -File .\scripts\cleanup_pytest_artifacts.ps1 -ListOnly -powershell -ExecutionPolicy Bypass -File .\scripts\cleanup_pytest_artifacts.ps1 -powershell -ExecutionPolicy Bypass -File .\scripts\cleanup_pytest_artifacts.ps1 -RepairAcl -``` +# Preview what would be removed +powershell -ExecutionPolicy Bypass -File .\scripts\cleanup_repo_artifacts.ps1 -ListOnly -`-RepairAcl` attempts a UAC-elevated ownership repair for legacy ACL-poisoned folders. Add `-IncludeLocalAppData` only if you also want to remove `%LOCALAPPDATA%\SelfSnap\pytest`. +# Remove all artifacts +powershell -ExecutionPolicy Bypass -File .\scripts\cleanup_repo_artifacts.ps1 + +# Attempt ACL repair (relaunches elevated via UAC), then remove +powershell -ExecutionPolicy Bypass -File .\scripts\cleanup_repo_artifacts.ps1 -RepairAcl + +# Exclude specific paths from removal +powershell -ExecutionPolicy Bypass -File .\scripts\cleanup_repo_artifacts.ps1 -Exclude ".coverage","coverage.xml" +``` The setup script prefers a uv-managed Python 3.12 interpreter when `uv` is installed, then falls back to a normal `python` or `py` launcher on PATH. If you need to force a specific interpreter, pass it explicitly: diff --git a/scripts/cleanup_repo_artifacts.ps1 b/scripts/cleanup_repo_artifacts.ps1 new file mode 100644 index 0000000..465ec1a --- /dev/null +++ b/scripts/cleanup_repo_artifacts.ps1 @@ -0,0 +1,248 @@ +param( + [switch]$ListOnly, + [switch]$RepairAcl, + [string[]]$Exclude = @(), + [switch]$RelaunchedElevated +) + +$ErrorActionPreference = "Stop" +$PSNativeCommandUseErrorActionPreference = $true + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + +function Test-Administrator { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +function Get-RelaunchArgumentList { + $arguments = @( + "-NoProfile", + "-ExecutionPolicy", "Bypass", + "-File", $PSCommandPath + ) + if ($RepairAcl) { $arguments += "-RepairAcl" } + if ($ListOnly) { $arguments += "-ListOnly" } + if ($Exclude.Count -gt 0) { $arguments += "-Exclude"; $arguments += $Exclude } + $arguments += "-RelaunchedElevated" + return $arguments +} + +# ── What this script removes ───────────────────────────────────────────────── +# +# Via git status (ignored + untracked): +# .pytest_cache/, .pytest_tmp/, .pytest-work/, pytest-cache-files-*/ +# .ruff_cache/, .mypy_cache/, .hypothesis/ +# .coverage, coverage.xml, cov_annotate/ +# __pycache__/, *.pyc, *.pyo, *.pyd, pytest*.out +# build/, dist/, *.egg-info/, .uv-cache/, tmp/, artifacts/ +# and any other git-ignored or untracked item +# +# Via explicit sweep (catches files that may be committed/tracked by git): +# cov_annotate/, .coverage, coverage.xml +# +# Via recursive directory sweep under the repo tree: +# __pycache__, .pytest_cache, .pytest_tmp, .pytest-work +# .ruff_cache, .mypy_cache, .hypothesis, .uv-cache, *.egg-info +# +# Via recursive file sweep: +# *.pyc, *.pyo, *.pyd, pytest*.out +# +# Protected (never removed even if gitignored): +# .venv/, venv/, .git/ +# +# ───────────────────────────────────────────────────────────────────────────── + +# Directories that are always protected — never removed even if gitignored. +$ProtectedTopLevel = [System.Collections.Generic.HashSet[string]]::new( + [string[]]@('.venv', 'venv', '.git'), + [StringComparer]::OrdinalIgnoreCase +) + +# Explicit paths relative to repo root that are targeted even when tracked by git. +$ExplicitPaths = @( + 'cov_annotate' + '.coverage' + 'coverage.xml' +) + +# Directory names removed recursively anywhere in the tree. +$RecursiveDirNames = @( + '__pycache__' + '.pytest_cache' + '.pytest_tmp' + '.pytest-work' + '.ruff_cache' + '.mypy_cache' + '.hypothesis' + '.uv-cache' +) + +# File patterns removed recursively anywhere in the tree. +$RecursiveFilePatterns = @( + '*.pyc' + '*.pyo' + '*.pyd' + 'pytest*.out' +) + +# ── Candidate collection ────────────────────────────────────────────────────── + +function Get-ArtifactCandidates { + param([string]$RepoRoot, [string[]]$ExcludeList) + + $seen = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + $candidates = [System.Collections.Generic.List[object]]::new() + + function Add-Candidate { + param([string]$FullPath, [string]$Label) + if (-not (Test-Path -LiteralPath $FullPath)) { return } + $relPath = $FullPath.Substring($RepoRoot.Length).TrimStart('\').TrimEnd('\') + if ([string]::IsNullOrWhiteSpace($relPath)) { return } + $topLevel = $relPath.Split('\')[0] + if ($ProtectedTopLevel.Contains($topLevel)) { return } + if ($ExcludeList -contains $relPath) { return } + if (-not $seen.Add($relPath)) { return } + $item = Get-Item -LiteralPath $FullPath -Force + $candidates.Add([pscustomobject]@{ + RelativePath = $relPath + FullPath = $item.FullName + IsDirectory = $item.PSIsContainer + Label = $Label + }) + } + + # Pass 1: git status — ignored (!!) and untracked (??) + $gitLines = & git -C $RepoRoot status --porcelain=v1 --ignored --untracked-files=all 2>&1 + foreach ($line in $gitLines) { + if ($line.Length -lt 4) { continue } + $code = $line.Substring(0, 2) + if ($code -ne '!!' -and $code -ne '??') { continue } + # git quotes paths that contain spaces or special characters — strip those quotes + $rel = $line.Substring(3).Trim().Trim('"').TrimEnd('/').Replace('/', '\') + $lbl = if ($code -eq '!!') { 'git-ignored' } else { 'untracked' } + Add-Candidate -FullPath (Join-Path $RepoRoot $rel) -Label $lbl + } + + # Pass 2: explicit known artifacts (catches committed files like coverage.xml) + foreach ($rel in $ExplicitPaths) { + Add-Candidate -FullPath (Join-Path $RepoRoot $rel) -Label 'explicit' + } + + # Pass 3: recursive directory sweep (skip protected top-level roots) + $searchRoots = @( + Get-ChildItem -Path $RepoRoot -Directory -Force -ErrorAction SilentlyContinue | + Where-Object { -not $ProtectedTopLevel.Contains($_.Name) } + ) + foreach ($dirName in $RecursiveDirNames) { + foreach ($root in $searchRoots) { + Get-ChildItem -Path $root.FullName -Filter $dirName -Recurse -Directory -Force -ErrorAction SilentlyContinue | + ForEach-Object { Add-Candidate -FullPath $_.FullName -Label 'recursive-dir' } + } + # searchRoots only recurses *inside* top-level dirs, so a matching dir + # sitting directly at repo root (e.g., $RepoRoot\__pycache__) would be + # missed; catch it here as a safety net. + Add-Candidate -FullPath (Join-Path $RepoRoot $dirName) -Label 'recursive-dir' + } + + # Pass 4: recursive file sweep (skip protected top-level roots) + foreach ($pattern in $RecursiveFilePatterns) { + foreach ($root in $searchRoots) { + Get-ChildItem -Path $root.FullName -Filter $pattern -Recurse -File -Force -ErrorAction SilentlyContinue | + ForEach-Object { Add-Candidate -FullPath $_.FullName -Label 'recursive-file' } + } + Get-ChildItem -Path $RepoRoot -Filter $pattern -File -Force -ErrorAction SilentlyContinue | + ForEach-Object { Add-Candidate -FullPath $_.FullName -Label 'recursive-file' } + } + + # Sort deepest paths first so children are removed before parents. + return @($candidates | Sort-Object @{ Expression = { ($_.RelativePath -split '\\').Count }; Descending = $true }, RelativePath) +} + +# ── Removal ─────────────────────────────────────────────────────────────────── + +function Remove-Artifact { + param([pscustomobject]$Item) + + if (-not (Test-Path -LiteralPath $Item.FullPath)) { + return [pscustomobject]@{ Path = $Item.RelativePath; Status = 'already-absent'; Note = '' } + } + + try { + Remove-Item -LiteralPath $Item.FullPath -Recurse -Force -ErrorAction Stop + return [pscustomobject]@{ Path = $Item.RelativePath; Status = 'removed'; Note = '' } + } + catch { + if (-not $RepairAcl) { + return [pscustomobject]@{ Path = $Item.RelativePath; Status = 'failed'; Note = $_.Exception.Message } + } + if (-not (Test-Administrator)) { + return [pscustomobject]@{ Path = $Item.RelativePath; Status = 'failed'; Note = 'ACL repair requires an elevated session.' } + } + try { + $null = takeown /F $Item.FullPath /R /D Y 2>&1 + $null = icacls $Item.FullPath /reset /T 2>&1 + Remove-Item -LiteralPath $Item.FullPath -Recurse -Force -ErrorAction Stop + return [pscustomobject]@{ Path = $Item.RelativePath; Status = 'removed'; Note = 'ACL repaired' } + } + catch { + return [pscustomobject]@{ Path = $Item.RelativePath; Status = 'failed'; Note = $_.Exception.Message } + } + } +} + +# ── Main ────────────────────────────────────────────────────────────────────── + +$candidates = Get-ArtifactCandidates -RepoRoot $repoRoot -ExcludeList $Exclude + +if ($candidates.Count -eq 0) { + Write-Host "No artifact candidates found under $repoRoot." + exit 0 +} + +Write-Host "Artifact candidates under ${repoRoot}:" +$candidates | + Select-Object Label, + @{ Name = 'Kind'; Expression = { if ($_.IsDirectory) { 'dir' } else { 'file' } } }, + RelativePath | + Format-Table -AutoSize | + Out-String | + Write-Host + +if ($ListOnly) { + exit 0 +} + +# ── Elevation for ACL repair ────────────────────────────────────────────────── + +if ($RepairAcl -and -not (Test-Administrator) -and -not $RelaunchedElevated) { + $relaunchArgs = Get-RelaunchArgumentList + Write-Host "Relaunching with elevation for ACL repair under $repoRoot..." + try { + $process = Start-Process -FilePath "powershell.exe" -Verb RunAs -WorkingDirectory $repoRoot -ArgumentList $relaunchArgs -Wait -PassThru + } + catch { + Write-Warning "Elevation was not completed. Cleanup was not performed." + exit 2 + } + exit $process.ExitCode +} + +# ── Removal ─────────────────────────────────────────────────────────────────── + +$results = foreach ($c in $candidates) { Remove-Artifact -Item $c } + +$results | Format-Table Path, Status, Note -AutoSize | Out-String | Write-Host + +$failed = @($results | Where-Object { $_.Status -eq 'failed' }) +if ($failed.Count -gt 0) { + Write-Warning "$($failed.Count) item(s) could not be removed. Run with -RepairAcl to attempt ACL repair." + if ($RepairAcl) { + exit 2 + } + exit 1 +} + +Write-Host "Repo artifact cleanup complete." +exit 0 \ No newline at end of file diff --git a/tests/unit/test_repo_hygiene.py b/tests/unit/test_repo_hygiene.py index b9523c4..a827aaa 100644 --- a/tests/unit/test_repo_hygiene.py +++ b/tests/unit/test_repo_hygiene.py @@ -3,22 +3,17 @@ from pathlib import Path -def test_pytest_artifact_cleanup_script_targets_acl_poisoned_folders() -> None: - script = Path("scripts/cleanup_pytest_artifacts.ps1").read_text(encoding="utf-8") +def test_repo_artifact_cleanup_script_targets_acl_poisoned_folders() -> None: + script = Path("scripts/cleanup_repo_artifacts.ps1").read_text(encoding="utf-8") - assert "takeown.exe" in script - assert "icacls.exe" in script - assert "pytest-cache-files-*" in script + assert "takeown" in script + assert "icacls" in script assert ".pytest_cache" in script - assert "Test-Administrator" in script + assert "cov_annotate" in script assert "RepairAcl" in script - assert "Start-Process" in script - assert "RunAs" in script - assert "Elevation returned, but pytest artifact targets still remain." in script - assert '-WorkingDirectory $repoRoot' in script - assert '"-NoProfile"' in script - assert 'Get-AllTargets -RepoRoot $repoRoot' in script - assert 'No pytest artifact directories were found under $repoRoot.' in script + assert "Get-ArtifactCandidates" in script + assert "Remove-Artifact" in script + assert "Remove-Item" in script def test_install_and_uninstall_scripts_support_interpreter_overrides() -> None: @@ -39,14 +34,14 @@ def test_install_and_uninstall_scripts_support_interpreter_overrides() -> None: assert "Stop-Process -Id $process.ProcessId -Force" in uninstall -def test_readme_documents_pytest_hygiene_workflow() -> None: +def test_readme_documents_cleanup_workflow() -> None: readme = Path("README.md").read_text(encoding="utf-8") - assert "cleanup_pytest_artifacts.ps1" in readme + assert "cleanup_repo_artifacts.ps1" in readme assert "pytest-cache-files-*" in readme assert "pytest cache provider is disabled" in readme.lower() assert "-RepairAcl" in readme - assert "legacy leftovers" in readme + assert "cov_annotate" in readme def test_pytest_config_keeps_temp_output_out_of_repo() -> None: