windowsweep 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AI-INTEGRATION-GUIDE.md +16 -12
- package/CHANGELOG.md +82 -0
- package/LICENSE +21 -21
- package/README.md +104 -69
- package/VERSION +1 -1
- package/lib/actions.ps1 +212 -212
- package/lib/config.ps1 +108 -99
- package/lib/constants.ps1 +103 -93
- package/lib/external.ps1 +87 -0
- package/lib/fs.ps1 +189 -180
- package/lib/log.ps1 +140 -140
- package/lib/safety.ps1 +467 -493
- package/lib/scan.ps1 +109 -100
- package/lib/ui.ps1 +275 -275
- package/modules/android_avd.ps1 +36 -36
- package/modules/app_caches.ps1 +58 -58
- package/modules/browsers.ps1 +33 -33
- package/modules/build_tools.ps1 +35 -35
- package/modules/crash_trap.ps1 +89 -89
- package/modules/disk_usage.ps1 +49 -49
- package/modules/docker.ps1 +93 -93
- package/modules/editors.ps1 +150 -150
- package/modules/globals_audit.ps1 +236 -236
- package/modules/health.ps1 +101 -101
- package/modules/installed_programs.ps1 +118 -118
- package/modules/menu.ps1 +63 -63
- package/modules/notify.ps1 +60 -60
- package/modules/orphaned_appdata.ps1 +189 -189
- package/modules/personal.ps1 +126 -126
- package/modules/pkg_managers.ps1 +73 -73
- package/modules/projects.ps1 +160 -160
- package/modules/release_helpers.ps1 +438 -438
- package/modules/reports.ps1 +212 -212
- package/modules/runner.ps1 +246 -239
- package/modules/self_test_contract.ps1 +195 -0
- package/modules/self_test_extra.ps1 +366 -360
- package/modules/startup_audit.ps1 +126 -126
- package/modules/system_admin.ps1 +220 -220
- package/modules/test_runners.ps1 +24 -24
- package/modules/walkthrough.ps1 +80 -79
- package/modules/windows_user.ps1 +118 -118
- package/package.json +3 -3
- package/windowsweep.ps1 +322 -316
package/modules/projects.ps1
CHANGED
|
@@ -1,160 +1,160 @@
|
|
|
1
|
-
# projects.ps1 - section 17: stale build artefacts in projects nobody has touched for N+ days. Interactive only.
|
|
2
|
-
|
|
3
|
-
$Script:WS_ARTEFACT_DIRS = @('node_modules', 'dist', 'build', 'out', '.next', '.nuxt', '.turbo', '.vite', '.svelte-kit', '.astro', '.parcel-cache', 'target', 'vendor', 'coverage', '.nyc_output', '__pycache__', '.pytest_cache', '.dart_tool', '.angular', '.nx', '.mypy_cache', '.ruff_cache', '.tox', '.eggs', '.output', '.serverless')
|
|
4
|
-
# Never here: .venv / venv (nobody expects an environment to vanish) and .terraform (provider binaries + lock state).
|
|
5
|
-
# A name in this table is an artefact ONLY when the same folder carries one of the listed markers: a bare
|
|
6
|
-
# .cache belongs to whoever made it, but beside a Gatsby or Parcel config it is a build cache.
|
|
7
|
-
$Script:WS_ARTEFACT_DIRS_MARKED = @{ '.cache' = @('gatsby-config.js', 'gatsby-config.ts', 'gatsby-config.mjs', '.parcelrc', 'parcel.config.js') }
|
|
8
|
-
$Script:WS_PROJECT_MARKERS = @('package.json', 'pubspec.yaml', 'composer.json', 'Cargo.toml', 'go.mod', 'build.gradle', 'build.gradle.kts', 'pom.xml', 'pyproject.toml', 'requirements.txt', 'angular.json', 'next.config.js', 'next.config.mjs', 'nuxt.config.ts', 'vite.config.ts', 'vite.config.js')
|
|
9
|
-
$Script:WS_NEVER_ENTER = @('.git', 'AppData', 'node_modules', '$RECYCLE.BIN', 'System Volume Information', '.gradle', '.m2', '.nuget', '.cargo', '.vscode', '.idea')
|
|
10
|
-
|
|
11
|
-
function Get-ProjectScanRoots {
|
|
12
|
-
$ws = $Script:WS
|
|
13
|
-
$U = $Script:P.U
|
|
14
|
-
$roots = @()
|
|
15
|
-
if ($ws.ScanRoots.Count -gt 0) {
|
|
16
|
-
foreach ($r in $ws.ScanRoots) {
|
|
17
|
-
$f = Get-FullPath $r
|
|
18
|
-
if (-not $f -or -not (Test-DirPresent $f)) { Write-Warn "scan root not found: $r"; continue }
|
|
19
|
-
if ($f -match '^[A-Za-z]:\\?$' -or $f.Equals($U, [StringComparison]::OrdinalIgnoreCase)) { Write-Warn "refusing to scan a whole drive or your profile root: $f (name a projects folder)"; continue }
|
|
20
|
-
$roots += $f
|
|
21
|
-
}
|
|
22
|
-
return $roots
|
|
23
|
-
}
|
|
24
|
-
$names = @('source\repos', 'source', 'repos', 'Projects', 'projects', 'code', 'Code', 'dev', 'Dev', 'Development', 'work', 'Work', 'git', 'GitHub', 'Documents\GitHub', 'Documents\Projects', 'Documents\code', 'Documents\dev', 'Documents\repos')
|
|
25
|
-
foreach ($n in $names) { $p = Join-Path $U $n; if (Test-DirPresent $p) { $roots += (Get-FullPath $p) } }
|
|
26
|
-
foreach ($d in [IO.DriveInfo]::GetDrives()) {
|
|
27
|
-
if (-not $d.IsReady -or $d.DriveType -ne [IO.DriveType]::Fixed) { continue }
|
|
28
|
-
foreach ($n in @('work', 'code', 'projects', 'repos', 'dev', 'src', 'git', 'Projects', 'Code', 'Work')) {
|
|
29
|
-
$p = Join-Path $d.Name $n
|
|
30
|
-
if ((Test-DirPresent $p) -and ($roots -notcontains (Get-FullPath $p))) { $roots += (Get-FullPath $p) }
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
return @($roots | Select-Object -Unique)
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function Get-Targets17 {
|
|
37
|
-
$roots = @(Get-ProjectScanRoots)
|
|
38
|
-
$t = @()
|
|
39
|
-
foreach ($r in $roots) { $t += (New-Target 17 'Project scan root' $r -Kind cmd -Note ($Script:WS_ARTEFACT_DIRS -join ', ')) }
|
|
40
|
-
if ($t.Count -eq 0) { $t += (New-Target 17 'Project scan root' '(none detected - pass --scan-roots "D:\work;E:\code")' -Kind cmd) }
|
|
41
|
-
return $t
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function Test-MarkedArtefactDir {
|
|
45
|
-
<# .SYNOPSIS True when Name is a marker-gated artefact folder AND Dir carries one of its markers. #>
|
|
46
|
-
param([string] $Dir, [string] $Name)
|
|
47
|
-
if (-not $Script:WS_ARTEFACT_DIRS_MARKED.ContainsKey($Name)) { return $false }
|
|
48
|
-
$names = @(Get-ChildEntries $Dir | ForEach-Object { $_.Name })
|
|
49
|
-
foreach ($m in $Script:WS_ARTEFACT_DIRS_MARKED[$Name]) { if ($names -contains $m) { return $true } }
|
|
50
|
-
return $false
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function Test-ProjectMarker {
|
|
54
|
-
param([string] $Dir, [string] $ArtefactName)
|
|
55
|
-
$entries = Get-ChildEntries $Dir
|
|
56
|
-
$names = @($entries | ForEach-Object { $_.Name })
|
|
57
|
-
if ($ArtefactName -in 'bin', 'obj') { return [bool]($names | Where-Object { $_ -like '*.csproj' -or $_ -like '*.sln' -or $_ -like '*.fsproj' }) }
|
|
58
|
-
foreach ($m in $Script:WS_PROJECT_MARKERS) { if ($names -contains $m) { return $true } }
|
|
59
|
-
return ($names -contains '.git')
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function Get-ProjectActivityDays {
|
|
63
|
-
<# .SYNOPSIS Days since any SOURCE file in the project changed (artefact dirs and .git excluded). #>
|
|
64
|
-
param([string] $ProjectDir)
|
|
65
|
-
$newest = [datetime]::MinValue
|
|
66
|
-
$stack = New-Object System.Collections.Generic.Stack[string]
|
|
67
|
-
$stack.Push($ProjectDir)
|
|
68
|
-
$seen = 0
|
|
69
|
-
while ($stack.Count -gt 0 -and $seen -lt 200000) {
|
|
70
|
-
$dir = $stack.Pop()
|
|
71
|
-
foreach ($e in (Get-ChildEntries $dir)) {
|
|
72
|
-
$seen++
|
|
73
|
-
if (Test-ReparsePoint $e) { continue }
|
|
74
|
-
if ($e -is [IO.DirectoryInfo]) {
|
|
75
|
-
if ($Script:WS_ARTEFACT_DIRS -contains $e.Name -or $Script:WS_NEVER_ENTER -contains $e.Name -or (Test-MarkedArtefactDir -Dir $dir -Name $e.Name)) { continue }
|
|
76
|
-
$stack.Push((Remove-LongPrefix $e.FullName)); continue
|
|
77
|
-
}
|
|
78
|
-
$t = Get-NewestTimestampUtc $e
|
|
79
|
-
if ($t -gt $newest) { $newest = $t }
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
if ($newest -eq [datetime]::MinValue) { return (Get-IdleDays $ProjectDir) }
|
|
83
|
-
return [int][math]::Floor(([datetime]::UtcNow - $newest).TotalDays)
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function Find-StaleArtefacts {
|
|
87
|
-
<# .SYNOPSIS Artefact folders (node_modules, dist, ...) whose project has been idle for N+ days. #>
|
|
88
|
-
param([string[]] $Roots, [int] $Days, [int] $MaxDepth = 6)
|
|
89
|
-
$ws = $Script:WS
|
|
90
|
-
$found = New-Object System.Collections.Generic.List[object]
|
|
91
|
-
$projectAge = @{}
|
|
92
|
-
foreach ($root in $Roots) {
|
|
93
|
-
$stack = New-Object System.Collections.Generic.Stack[object]
|
|
94
|
-
$stack.Push(@($root, 0))
|
|
95
|
-
while ($stack.Count -gt 0) {
|
|
96
|
-
$item = $stack.Pop()
|
|
97
|
-
$dir = [string]$item[0]; $depth = [int]$item[1]
|
|
98
|
-
foreach ($e in (Get-ChildEntries $dir)) {
|
|
99
|
-
if (-not ($e -is [IO.DirectoryInfo]) -or (Test-ReparsePoint $e)) { continue }
|
|
100
|
-
$full = Remove-LongPrefix $e.FullName
|
|
101
|
-
$skip = $false
|
|
102
|
-
foreach ($x in $ws.ExcludePaths) { if ($x -and (Test-PathWithin -Path $full -Within $x)) { $skip = $true; break } }
|
|
103
|
-
if ($skip) { continue }
|
|
104
|
-
if ($Script:WS_ARTEFACT_DIRS -contains $e.Name -or ($e.Name -in 'bin', 'obj') -or (Test-MarkedArtefactDir -Dir $dir -Name $e.Name)) {
|
|
105
|
-
if (-not (Test-ProjectMarker -Dir $dir -ArtefactName $e.Name)) { continue }
|
|
106
|
-
if (-not $projectAge.ContainsKey($dir)) { $projectAge[$dir] = Get-ProjectActivityDays $dir }
|
|
107
|
-
$age = $projectAge[$dir]
|
|
108
|
-
if ($age -ge $Days) { $found.Add([pscustomobject]@{ Path = $full; Project = $dir; Age = $age; Bytes = [long](Get-DirectoryBytes $full); Root = $root }) }
|
|
109
|
-
continue
|
|
110
|
-
}
|
|
111
|
-
if ($Script:WS_NEVER_ENTER -contains $e.Name -or $e.Name.StartsWith('.')) { continue }
|
|
112
|
-
if ($depth -lt $MaxDepth) { $stack.Push(@($full, $depth + 1)) }
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
return @($found | Sort-Object Bytes -Descending)
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function Invoke-Section17 {
|
|
120
|
-
$ws = $Script:WS
|
|
121
|
-
Write-SectionIntro @(
|
|
122
|
-
'node_modules, dist, .next, target, vendor and friends are rebuilt by the next install or build. Only projects',
|
|
123
|
-
"whose SOURCE files nobody touched for $($ws.Days)+ days are listed, and nothing goes without your selection.",
|
|
124
|
-
'Toolchain folders (.gradle, .nuget, .cargo, AppData) and .git are never entered.'
|
|
125
|
-
)
|
|
126
|
-
$roots = @(Get-ProjectScanRoots)
|
|
127
|
-
if ($roots.Count -eq 0) { Write-Warn 'no project roots found. Pass --scan-roots "D:\work;E:\code" (semicolon-separated) or save scanRoots in config.json.'; return }
|
|
128
|
-
Write-Info ("scanning: " + ($roots -join ' '))
|
|
129
|
-
$found = @(Find-StaleArtefacts -Roots $roots -Days $ws.Days)
|
|
130
|
-
if ($found.Count -eq 0) { Write-Info "no build artefacts in projects idle $($ws.Days)+ days"; return }
|
|
131
|
-
$reportLines = @("stale build artefacts - projects idle $($ws.Days)+ days - $(Get-Date -Format 'yyyy-MM-dd HH:mm')", '')
|
|
132
|
-
# One offered list drives the table, the JSON candidates and the selection indexes: they cannot disagree.
|
|
133
|
-
$offered = @($found | Select-Object -First 200)
|
|
134
|
-
if ($found.Count -gt $offered.Count) { Write-Warn 'list truncated to 200 entries' }
|
|
135
|
-
Write-UiLine (" {0,3} {1,10} {2,6} {3}" -f '#', 'SIZE', 'IDLE', 'ARTEFACT') 'White'
|
|
136
|
-
$i = 1; $total = [long]0
|
|
137
|
-
foreach ($f in $offered) {
|
|
138
|
-
Write-UiLine (" {0,3} {1,10} {2,5}d {3}" -f $i, (Format-Bytes $f.Bytes), $f.Age, $f.Path) 'Gray'
|
|
139
|
-
$reportLines += ("{0,10} {1,5}d {2}" -f (Format-Bytes $f.Bytes), $f.Age, $f.Path)
|
|
140
|
-
Add-JsonCandidate -Section 17 -Index $i -Path $f.Path -Bytes $f.Bytes -IdleDays $f.Age -Project $f.Project
|
|
141
|
-
$total += $f.Bytes; $i++
|
|
142
|
-
}
|
|
143
|
-
Write-Info ("total: " + (Format-Bytes $total))
|
|
144
|
-
if (-not $ws.NoReport) {
|
|
145
|
-
$out = Join-Path $ws.ReportsDir ('stale-builds-' + $ws.Stamp + '.txt')
|
|
146
|
-
try { [IO.File]::WriteAllLines($out, $reportLines); Write-Note "list saved: $out" } catch { $null = $_ }
|
|
147
|
-
}
|
|
148
|
-
$picks = @(Read-MultiSelect -Total $offered.Count -NoAutoYes -Candidates @($offered | ForEach-Object { $_.Path }))
|
|
149
|
-
if ($picks.Count -eq 0) { Write-Info 'nothing selected'; return }
|
|
150
|
-
if (-not $ws.DryRun) {
|
|
151
|
-
# Project artefacts never auto-confirm: --yes does not apply, and the walkthrough's step answer is not a selection.
|
|
152
|
-
# An explicit --select / --select-file choice does answer it - that is a person choosing, in advance.
|
|
153
|
-
if (-not (Confirm-Ui -Prompt "Remove $($picks.Count) artefact folder(s)? (rebuild with the project's install/build command)" -Default 'n' -NoAutoYes -ScriptedOk)) { Write-Info 'skipped'; return }
|
|
154
|
-
}
|
|
155
|
-
foreach ($k in $picks) {
|
|
156
|
-
$f = $offered[$k - 1]
|
|
157
|
-
$r = Remove-PathSafe -Path $f.Path -Within $f.Root -Label (Split-Path -Leaf $f.Path)
|
|
158
|
-
if ($r.Removed -and -not $ws.DryRun) { Write-Ok ("removed " + (Format-Bytes $r.Bytes) + " $($f.Path)") }
|
|
159
|
-
}
|
|
160
|
-
}
|
|
1
|
+
# projects.ps1 - section 17: stale build artefacts in projects nobody has touched for N+ days. Interactive only.
|
|
2
|
+
|
|
3
|
+
$Script:WS_ARTEFACT_DIRS = @('node_modules', 'dist', 'build', 'out', '.next', '.nuxt', '.turbo', '.vite', '.svelte-kit', '.astro', '.parcel-cache', 'target', 'vendor', 'coverage', '.nyc_output', '__pycache__', '.pytest_cache', '.dart_tool', '.angular', '.nx', '.mypy_cache', '.ruff_cache', '.tox', '.eggs', '.output', '.serverless')
|
|
4
|
+
# Never here: .venv / venv (nobody expects an environment to vanish) and .terraform (provider binaries + lock state).
|
|
5
|
+
# A name in this table is an artefact ONLY when the same folder carries one of the listed markers: a bare
|
|
6
|
+
# .cache belongs to whoever made it, but beside a Gatsby or Parcel config it is a build cache.
|
|
7
|
+
$Script:WS_ARTEFACT_DIRS_MARKED = @{ '.cache' = @('gatsby-config.js', 'gatsby-config.ts', 'gatsby-config.mjs', '.parcelrc', 'parcel.config.js') }
|
|
8
|
+
$Script:WS_PROJECT_MARKERS = @('package.json', 'pubspec.yaml', 'composer.json', 'Cargo.toml', 'go.mod', 'build.gradle', 'build.gradle.kts', 'pom.xml', 'pyproject.toml', 'requirements.txt', 'angular.json', 'next.config.js', 'next.config.mjs', 'nuxt.config.ts', 'vite.config.ts', 'vite.config.js')
|
|
9
|
+
$Script:WS_NEVER_ENTER = @('.git', 'AppData', 'node_modules', '$RECYCLE.BIN', 'System Volume Information', '.gradle', '.m2', '.nuget', '.cargo', '.vscode', '.idea')
|
|
10
|
+
|
|
11
|
+
function Get-ProjectScanRoots {
|
|
12
|
+
$ws = $Script:WS
|
|
13
|
+
$U = $Script:P.U
|
|
14
|
+
$roots = @()
|
|
15
|
+
if ($ws.ScanRoots.Count -gt 0) {
|
|
16
|
+
foreach ($r in $ws.ScanRoots) {
|
|
17
|
+
$f = Get-FullPath $r
|
|
18
|
+
if (-not $f -or -not (Test-DirPresent $f)) { Write-Warn "scan root not found: $r"; continue }
|
|
19
|
+
if ($f -match '^[A-Za-z]:\\?$' -or $f.Equals($U, [StringComparison]::OrdinalIgnoreCase)) { Write-Warn "refusing to scan a whole drive or your profile root: $f (name a projects folder)"; continue }
|
|
20
|
+
$roots += $f
|
|
21
|
+
}
|
|
22
|
+
return $roots
|
|
23
|
+
}
|
|
24
|
+
$names = @('source\repos', 'source', 'repos', 'Projects', 'projects', 'code', 'Code', 'dev', 'Dev', 'Development', 'work', 'Work', 'git', 'GitHub', 'Documents\GitHub', 'Documents\Projects', 'Documents\code', 'Documents\dev', 'Documents\repos')
|
|
25
|
+
foreach ($n in $names) { $p = Join-Path $U $n; if (Test-DirPresent $p) { $roots += (Get-FullPath $p) } }
|
|
26
|
+
foreach ($d in [IO.DriveInfo]::GetDrives()) {
|
|
27
|
+
if (-not $d.IsReady -or $d.DriveType -ne [IO.DriveType]::Fixed) { continue }
|
|
28
|
+
foreach ($n in @('work', 'code', 'projects', 'repos', 'dev', 'src', 'git', 'Projects', 'Code', 'Work')) {
|
|
29
|
+
$p = Join-Path $d.Name $n
|
|
30
|
+
if ((Test-DirPresent $p) -and ($roots -notcontains (Get-FullPath $p))) { $roots += (Get-FullPath $p) }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return @($roots | Select-Object -Unique)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function Get-Targets17 {
|
|
37
|
+
$roots = @(Get-ProjectScanRoots)
|
|
38
|
+
$t = @()
|
|
39
|
+
foreach ($r in $roots) { $t += (New-Target 17 'Project scan root' $r -Kind cmd -Note ($Script:WS_ARTEFACT_DIRS -join ', ')) }
|
|
40
|
+
if ($t.Count -eq 0) { $t += (New-Target 17 'Project scan root' '(none detected - pass --scan-roots "D:\work;E:\code")' -Kind cmd) }
|
|
41
|
+
return $t
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function Test-MarkedArtefactDir {
|
|
45
|
+
<# .SYNOPSIS True when Name is a marker-gated artefact folder AND Dir carries one of its markers. #>
|
|
46
|
+
param([string] $Dir, [string] $Name)
|
|
47
|
+
if (-not $Script:WS_ARTEFACT_DIRS_MARKED.ContainsKey($Name)) { return $false }
|
|
48
|
+
$names = @(Get-ChildEntries $Dir | ForEach-Object { $_.Name })
|
|
49
|
+
foreach ($m in $Script:WS_ARTEFACT_DIRS_MARKED[$Name]) { if ($names -contains $m) { return $true } }
|
|
50
|
+
return $false
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function Test-ProjectMarker {
|
|
54
|
+
param([string] $Dir, [string] $ArtefactName)
|
|
55
|
+
$entries = Get-ChildEntries $Dir
|
|
56
|
+
$names = @($entries | ForEach-Object { $_.Name })
|
|
57
|
+
if ($ArtefactName -in 'bin', 'obj') { return [bool]($names | Where-Object { $_ -like '*.csproj' -or $_ -like '*.sln' -or $_ -like '*.fsproj' }) }
|
|
58
|
+
foreach ($m in $Script:WS_PROJECT_MARKERS) { if ($names -contains $m) { return $true } }
|
|
59
|
+
return ($names -contains '.git')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function Get-ProjectActivityDays {
|
|
63
|
+
<# .SYNOPSIS Days since any SOURCE file in the project changed (artefact dirs and .git excluded). #>
|
|
64
|
+
param([string] $ProjectDir)
|
|
65
|
+
$newest = [datetime]::MinValue
|
|
66
|
+
$stack = New-Object System.Collections.Generic.Stack[string]
|
|
67
|
+
$stack.Push($ProjectDir)
|
|
68
|
+
$seen = 0
|
|
69
|
+
while ($stack.Count -gt 0 -and $seen -lt 200000) {
|
|
70
|
+
$dir = $stack.Pop()
|
|
71
|
+
foreach ($e in (Get-ChildEntries $dir)) {
|
|
72
|
+
$seen++
|
|
73
|
+
if (Test-ReparsePoint $e) { continue }
|
|
74
|
+
if ($e -is [IO.DirectoryInfo]) {
|
|
75
|
+
if ($Script:WS_ARTEFACT_DIRS -contains $e.Name -or $Script:WS_NEVER_ENTER -contains $e.Name -or (Test-MarkedArtefactDir -Dir $dir -Name $e.Name)) { continue }
|
|
76
|
+
$stack.Push((Remove-LongPrefix $e.FullName)); continue
|
|
77
|
+
}
|
|
78
|
+
$t = Get-NewestTimestampUtc $e
|
|
79
|
+
if ($t -gt $newest) { $newest = $t }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if ($newest -eq [datetime]::MinValue) { return (Get-IdleDays $ProjectDir) }
|
|
83
|
+
return [int][math]::Floor(([datetime]::UtcNow - $newest).TotalDays)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function Find-StaleArtefacts {
|
|
87
|
+
<# .SYNOPSIS Artefact folders (node_modules, dist, ...) whose project has been idle for N+ days. #>
|
|
88
|
+
param([string[]] $Roots, [int] $Days, [int] $MaxDepth = 6)
|
|
89
|
+
$ws = $Script:WS
|
|
90
|
+
$found = New-Object System.Collections.Generic.List[object]
|
|
91
|
+
$projectAge = @{}
|
|
92
|
+
foreach ($root in $Roots) {
|
|
93
|
+
$stack = New-Object System.Collections.Generic.Stack[object]
|
|
94
|
+
$stack.Push(@($root, 0))
|
|
95
|
+
while ($stack.Count -gt 0) {
|
|
96
|
+
$item = $stack.Pop()
|
|
97
|
+
$dir = [string]$item[0]; $depth = [int]$item[1]
|
|
98
|
+
foreach ($e in (Get-ChildEntries $dir)) {
|
|
99
|
+
if (-not ($e -is [IO.DirectoryInfo]) -or (Test-ReparsePoint $e)) { continue }
|
|
100
|
+
$full = Remove-LongPrefix $e.FullName
|
|
101
|
+
$skip = $false
|
|
102
|
+
foreach ($x in $ws.ExcludePaths) { if ($x -and (Test-PathWithin -Path $full -Within $x)) { $skip = $true; break } }
|
|
103
|
+
if ($skip) { continue }
|
|
104
|
+
if ($Script:WS_ARTEFACT_DIRS -contains $e.Name -or ($e.Name -in 'bin', 'obj') -or (Test-MarkedArtefactDir -Dir $dir -Name $e.Name)) {
|
|
105
|
+
if (-not (Test-ProjectMarker -Dir $dir -ArtefactName $e.Name)) { continue }
|
|
106
|
+
if (-not $projectAge.ContainsKey($dir)) { $projectAge[$dir] = Get-ProjectActivityDays $dir }
|
|
107
|
+
$age = $projectAge[$dir]
|
|
108
|
+
if ($age -ge $Days) { $found.Add([pscustomobject]@{ Path = $full; Project = $dir; Age = $age; Bytes = [long](Get-DirectoryBytes $full); Root = $root }) }
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
if ($Script:WS_NEVER_ENTER -contains $e.Name -or $e.Name.StartsWith('.')) { continue }
|
|
112
|
+
if ($depth -lt $MaxDepth) { $stack.Push(@($full, $depth + 1)) }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return @($found | Sort-Object Bytes -Descending)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function Invoke-Section17 {
|
|
120
|
+
$ws = $Script:WS
|
|
121
|
+
Write-SectionIntro @(
|
|
122
|
+
'node_modules, dist, .next, target, vendor and friends are rebuilt by the next install or build. Only projects',
|
|
123
|
+
"whose SOURCE files nobody touched for $($ws.Days)+ days are listed, and nothing goes without your selection.",
|
|
124
|
+
'Toolchain folders (.gradle, .nuget, .cargo, AppData) and .git are never entered.'
|
|
125
|
+
)
|
|
126
|
+
$roots = @(Get-ProjectScanRoots)
|
|
127
|
+
if ($roots.Count -eq 0) { Write-Warn 'no project roots found. Pass --scan-roots "D:\work;E:\code" (semicolon-separated) or save scanRoots in config.json.'; return }
|
|
128
|
+
Write-Info ("scanning: " + ($roots -join ' '))
|
|
129
|
+
$found = @(Find-StaleArtefacts -Roots $roots -Days $ws.Days)
|
|
130
|
+
if ($found.Count -eq 0) { Write-Info "no build artefacts in projects idle $($ws.Days)+ days"; return }
|
|
131
|
+
$reportLines = @("stale build artefacts - projects idle $($ws.Days)+ days - $(Get-Date -Format 'yyyy-MM-dd HH:mm')", '')
|
|
132
|
+
# One offered list drives the table, the JSON candidates and the selection indexes: they cannot disagree.
|
|
133
|
+
$offered = @($found | Select-Object -First 200)
|
|
134
|
+
if ($found.Count -gt $offered.Count) { Write-Warn 'list truncated to 200 entries' }
|
|
135
|
+
Write-UiLine (" {0,3} {1,10} {2,6} {3}" -f '#', 'SIZE', 'IDLE', 'ARTEFACT') 'White'
|
|
136
|
+
$i = 1; $total = [long]0
|
|
137
|
+
foreach ($f in $offered) {
|
|
138
|
+
Write-UiLine (" {0,3} {1,10} {2,5}d {3}" -f $i, (Format-Bytes $f.Bytes), $f.Age, $f.Path) 'Gray'
|
|
139
|
+
$reportLines += ("{0,10} {1,5}d {2}" -f (Format-Bytes $f.Bytes), $f.Age, $f.Path)
|
|
140
|
+
Add-JsonCandidate -Section 17 -Index $i -Path $f.Path -Bytes $f.Bytes -IdleDays $f.Age -Project $f.Project
|
|
141
|
+
$total += $f.Bytes; $i++
|
|
142
|
+
}
|
|
143
|
+
Write-Info ("total: " + (Format-Bytes $total))
|
|
144
|
+
if (-not $ws.NoReport) {
|
|
145
|
+
$out = Join-Path $ws.ReportsDir ('stale-builds-' + $ws.Stamp + '.txt')
|
|
146
|
+
try { [IO.File]::WriteAllLines($out, $reportLines); Write-Note "list saved: $out" } catch { $null = $_ }
|
|
147
|
+
}
|
|
148
|
+
$picks = @(Read-MultiSelect -Total $offered.Count -NoAutoYes -Candidates @($offered | ForEach-Object { $_.Path }))
|
|
149
|
+
if ($picks.Count -eq 0) { Write-Info 'nothing selected'; return }
|
|
150
|
+
if (-not $ws.DryRun) {
|
|
151
|
+
# Project artefacts never auto-confirm: --yes does not apply, and the walkthrough's step answer is not a selection.
|
|
152
|
+
# An explicit --select / --select-file choice does answer it - that is a person choosing, in advance.
|
|
153
|
+
if (-not (Confirm-Ui -Prompt "Remove $($picks.Count) artefact folder(s)? (rebuild with the project's install/build command)" -Default 'n' -NoAutoYes -ScriptedOk)) { Write-Info 'skipped'; return }
|
|
154
|
+
}
|
|
155
|
+
foreach ($k in $picks) {
|
|
156
|
+
$f = $offered[$k - 1]
|
|
157
|
+
$r = Remove-PathSafe -Path $f.Path -Within $f.Root -Label (Split-Path -Leaf $f.Path)
|
|
158
|
+
if ($r.Removed -and -not $ws.DryRun) { Write-Ok ("removed " + (Format-Bytes $r.Bytes) + " $($f.Path)") }
|
|
159
|
+
}
|
|
160
|
+
}
|