Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 65 additions & 40 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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:

Expand Down
248 changes: 248 additions & 0 deletions scripts/cleanup_repo_artifacts.ps1
Original file line number Diff line number Diff line change
@@ -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 }
}
Comment on lines +176 to +191

Copilot AI Apr 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When -RepairAcl is enabled, the script attempts takeown/icacls but doesn't check for admin rights or relaunch elevated. In a non-elevated session this will typically fail and surface only the raw exception message, which is less actionable than explicitly detecting the non-admin case (and optionally relaunching with -Verb RunAs, like the earlier cleanup_pytest_artifacts.ps1 behavior).

Copilot uses AI. Check for mistakes.
}
}

# ── 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
Loading
Loading