Private/Measure-FolderSize.ps1
|
#Requires -Version 5.1 function Measure-FolderSize { <# .SYNOPSIS Measures one level of a directory tree (pure sizing helper, no side effects). .DESCRIPTION Enumerates the immediate children of a single parent directory and returns one [PSCustomObject] per immediate child directory, each carrying the recursive size and file count of that child, plus one synthetic entry that aggregates the loose files sitting directly under the parent. It walks only one level: grandchildren are summed into their parent child's totals but are never emitted, and the parent is never traversed beyond its children. The helper is defect-avoidance by design. It absorbs access-denied and long-path errors into a per-child Inaccessible count instead of aborting, so one unreadable subfolder cannot discard the whole result. Reparse points (junctions, symlinks) are skipped rather than traversed to avoid double counting and loops. It exists as a pure, unit-testable seam for the future Watch-DriveUsage console loop; it does not sort and it does not render. An optional -OnProgress callback lets a caller observe the walk as it proceeds without the helper itself performing any console I/O. .PARAMETER Path The single parent directory to enumerate one level of. Mandatory. Must be an existing directory; a non-existent path or a path pointing at a file produces a non-terminating error and returns nothing. .PARAMETER IncludeFiles When set, also emit one entry per file directly under Path (IsContainer = $false) in addition to the aggregated loose-files entry. Off by default. .PARAMETER OnProgress Optional callback scriptblock invoked as the level is measured. It receives one [PSCustomObject] per report with FolderIndex, FolderCount, FileCount, Bytes and CurrentName, so a caller can render a live progress indicator. .PARAMETER CollectGrandchildren When set, also accumulate per-grandchild-directory totals during the single subtree walk it already performs, and return them through -GrandchildMap. Off by default, so the primary output and the default walk are unchanged. .PARAMETER GrandchildMap A [ref] to a hashtable that receives the per-grandchild totals, keyed by grandchild full path. Each value is an object with SizeBytes and FileCount. Only populated when -CollectGrandchildren is set. .EXAMPLE Measure-FolderSize -Path 'C:\Temp' Returns one entry per immediate child directory of C:\Temp plus a '(files)' entry aggregating the loose files directly under C:\Temp. .EXAMPLE Measure-FolderSize -Path 'D:\Data' -IncludeFiles As above, but also emits an entry for each individual file directly under D:\Data with IsContainer = $false. .EXAMPLE Measure-FolderSize -Path 'C:\Windows' | Sort-Object -Property SizeBytes -Descending | Select-Object -First 5 Pipes the per-child measurements to the caller for sorting and selection; the helper itself never sorts its output. .OUTPUTS System.Management.Automation.PSCustomObject One object per immediate child directory, one aggregated '(files)' entry, and (with -IncludeFiles) one object per loose file. Each has Name, FullName, SizeBytes, FileCount, IsContainer and Inaccessible. .NOTES Author: Franck SALLET Version: 1.0.0 Last Modified: 2026-09-25 Requires: PowerShell 5.1+ / Windows only Scope: Private - not exported #> [CmdletBinding()] [OutputType([System.Management.Automation.PSCustomObject])] param ( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string]$Path, [Parameter(Mandatory = $false)] [switch]$IncludeFiles, [Parameter(Mandatory = $false)] [scriptblock]$OnProgress, [Parameter(Mandatory = $false)] [switch]$CollectGrandchildren, [Parameter(Mandatory = $false)] [ref]$GrandchildMap ) process { # Keep the caller alive: a bad path is a non-terminating error, never a throw. $item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue if ($null -eq $item) { Write-Error "[$($MyInvocation.MyCommand)] Path '$Path' does not exist." return } if (-not $item.PSIsContainer) { Write-Error "[$($MyInvocation.MyCommand)] Path '$Path' is a file, not a directory." return } $reparse = [System.IO.FileAttributes]::ReparsePoint $collect = $CollectGrandchildren.IsPresent $grandMap = @{} # ---- Immediate child directories ---- $childErrors = $null $childDirs = Get-ChildItem -LiteralPath $Path -Force -Directory ` -ErrorAction SilentlyContinue -ErrorVariable childErrors $topInaccessible = @($childErrors).Count # Progress reporting. The optional -OnProgress callback is invoked once at the # start of each child (so a folder counter can advance) and, inside a large # recursion, at most every 100 ms (so a file counter keeps ticking). Counts are # cumulative across the whole level; the helper itself performs no console I/O. $dirCount = @($childDirs).Count $dirIndex = 0 $totalFiles = [long]0 $totalBytes = [long]0 $scanState = [pscustomobject]@{ Stopwatch = [System.Diagnostics.Stopwatch]::StartNew() LastReport = [long]0 } $report = { param($FolderIndex, $FolderCount, $FileCount, $Bytes, $CurrentName) $null = & $OnProgress ([PSCustomObject]@{ FolderIndex = $FolderIndex FolderCount = $FolderCount FileCount = $FileCount Bytes = $Bytes CurrentName = $CurrentName }) } foreach ($dir in $childDirs) { $dirIndex++ # Reparse points are skipped, not traversed: emit a zero-size marker. if (($dir.Attributes -band $reparse) -eq $reparse) { if ($null -ne $OnProgress) { & $report $dirIndex $dirCount $totalFiles $totalBytes $dir.Name } [PSCustomObject]@{ Name = $dir.Name FullName = $dir.FullName SizeBytes = [long]0 FileCount = [long]0 IsContainer = $true Inaccessible = [long]1 } continue } if ($null -ne $OnProgress) { & $report $dirIndex $dirCount $totalFiles $totalBytes $dir.Name } $subErrors = $null $accum = [pscustomobject]@{ Size = [long]0; Count = [long]0 } $dirFullLen = $dir.FullName.Length Get-ChildItem -LiteralPath $dir.FullName -Force -Recurse -File ` -ErrorAction SilentlyContinue -ErrorVariable subErrors | ForEach-Object { $accum.Size += [long]$_.Length $accum.Count++ if ($collect) { # A file one level below $dir belongs to a grandchild of $Path; # files directly under $dir (no further separator) have none. $rel = $_.FullName.Substring($dirFullLen).TrimStart('\') $sep = $rel.IndexOf('\') if ($sep -gt 0) { $grandPath = $dir.FullName + '\' + $rel.Substring(0, $sep) if ($grandMap.ContainsKey($grandPath)) { $totals = $grandMap[$grandPath] $totals.SizeBytes += [long]$_.Length $totals.FileCount++ } else { $grandMap[$grandPath] = [PSCustomObject]@{ SizeBytes = [long]$_.Length FileCount = [long]1 } } } } if ($null -ne $OnProgress -and ($scanState.Stopwatch.ElapsedMilliseconds - $scanState.LastReport) -ge 100) { & $report $dirIndex $dirCount ($totalFiles + $accum.Count) ($totalBytes + $accum.Size) $dir.Name $scanState.LastReport = $scanState.Stopwatch.ElapsedMilliseconds } } $totalFiles += $accum.Count $totalBytes += $accum.Size [PSCustomObject]@{ Name = $dir.Name FullName = $dir.FullName SizeBytes = $accum.Size FileCount = $accum.Count IsContainer = $true Inaccessible = [long]@($subErrors).Count } } # Final cumulative report so the caller can show the completed counts. if ($null -ne $OnProgress -and $dirCount -gt 0) { & $report $dirCount $dirCount $totalFiles $totalBytes '' } # ---- Loose files directly under Path ---- $fileErrors = $null $looseFiles = Get-ChildItem -LiteralPath $Path -Force -File ` -ErrorAction SilentlyContinue -ErrorVariable fileErrors $looseSize = [long]0 $looseCount = [long]0 foreach ($file in $looseFiles) { $looseSize += [long]$file.Length $looseCount++ } [PSCustomObject]@{ Name = '(files)' FullName = $Path SizeBytes = $looseSize FileCount = $looseCount IsContainer = $true Inaccessible = [long]($topInaccessible + @($fileErrors).Count) } # ---- Optional per-file entries ---- if ($IncludeFiles) { foreach ($file in $looseFiles) { [PSCustomObject]@{ Name = $file.Name FullName = $file.FullName SizeBytes = [long]$file.Length FileCount = [long]1 IsContainer = $false Inaccessible = [long]0 } } } # ---- Per-grandchild totals (only when requested) ---- if ($collect -and $null -ne $GrandchildMap) { $GrandchildMap.Value = $grandMap } } } |