<# .SYNOPSIS Shows a folder tree with file counts and sizes, and optionally saves it as CSV. .DESCRIPTION Walks a folder once, adds up every file into its own folder and all of the folders above it, then returns one object per folder in tree order: how many files sit directly in it, how many are in it and everything below, and the total size. Use -Depth to keep the listing short without changing the totals, -IncludeFiles to list the files as well, and -CsvPath to save the rows for Excel. Folders you can't read are counted as warnings, not errors. .PARAMETER Path The folder to report on. Default: the current folder. .PARAMETER Depth How many levels below Path to list. Totals always include everything underneath. Default: all levels. .PARAMETER IncludeFiles List each file under its folder too. .PARAMETER IncludeHidden Count hidden and system files and folders. .PARAMETER CsvPath Also save the rows to this CSV file. .EXAMPLE .\Get-DirectoryReport.ps1 -Path D:\Shared -Depth 2 .EXAMPLE .\Get-DirectoryReport.ps1 -Path D:\Shared -CsvPath .\shared-report.csv | Sort-Object SizeMB -Descending | Select-Object -First 10 #> [CmdletBinding()] param( [Parameter(Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)] [Alias('FullName')] [ValidateNotNullOrEmpty()] [string]$Path = '.', [ValidateRange(0, 1000)][int]$Depth = 1000, [switch]$IncludeFiles, [switch]$IncludeHidden, [string]$CsvPath ) process { try { $root = (Get-Item -LiteralPath $Path -Force -ErrorAction Stop) } catch { Write-Error "Can't open '$Path': $($_.Exception.Message)" return } if (-not $root.PSIsContainer) { Write-Error "'$Path' is a file, not a folder."; return } # Trim a trailing slash, except on a drive root like C:\ where it matters. $rootPath = $root.FullName if ($rootPath.Length -gt [IO.Path]::GetPathRoot($rootPath).Length) { $rootPath = $rootPath.TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) } $sep = [IO.Path]::DirectorySeparatorChar # Show the useful columns by default; the rest are still there for Select-Object and the CSV. $display = [System.Management.Automation.PSPropertySet]::new('DefaultDisplayPropertySet', [string[]]@('Tree', 'Files', 'TotalFiles', 'SizeMB')) $standardMembers = [System.Management.Automation.PSMemberInfo[]]@($display) # One pass for folders, one for files. Unreadable folders land in $denied instead of stopping the run. $denied = @() $folders = @(Get-ChildItem -LiteralPath $rootPath -Directory -Recurse -Force:$IncludeHidden -ErrorAction SilentlyContinue -ErrorVariable +denied) $files = @(Get-ChildItem -LiteralPath $rootPath -File -Recurse -Force:$IncludeHidden -ErrorAction SilentlyContinue -ErrorVariable +denied) if ($denied) { Write-Warning "$($denied.Count) item(s) couldn't be read (usually access denied). Totals leave them out. Run with -Verbose to see which." } foreach ($d in $denied) { Write-Verbose "Skipped: $($d.TargetObject)" } # A stats entry per folder, plus a child list so the tree can be walked in order. $stats = @{} $children = @{} $newStat = { [pscustomobject]@{ Files = 0; TotalFiles = 0; Bytes = [long]0; Folders = 0; LastWrite = $null } } $stats[$rootPath] = & $newStat $children[$rootPath] = [System.Collections.Generic.List[string]]::new() foreach ($f in $folders) { $stats[$f.FullName] = & $newStat $children[$f.FullName] = [System.Collections.Generic.List[string]]::new() } foreach ($f in $folders) { $parent = [IO.Path]::GetDirectoryName($f.FullName) if ($children.ContainsKey($parent)) { $children[$parent].Add($f.FullName); $stats[$parent].Folders++ } } $filesByFolder = @{} foreach ($file in $files) { $dir = $file.DirectoryName if (-not $stats.ContainsKey($dir)) { continue } # its folder was hidden or unreadable $stats[$dir].Files++ if (-not $filesByFolder.ContainsKey($dir)) { $filesByFolder[$dir] = [System.Collections.Generic.List[object]]::new() } $filesByFolder[$dir].Add($file) # Roll the file up into this folder and every folder above it, stopping at the root. $walk = $dir while ($walk) { $s = $stats[$walk] $s.TotalFiles++ $s.Bytes += $file.Length if (-not $s.LastWrite -or $file.LastWriteTime -gt $s.LastWrite) { $s.LastWrite = $file.LastWriteTime } if ($walk -eq $rootPath) { break } $walk = [IO.Path]::GetDirectoryName($walk) if (-not $stats.ContainsKey($walk)) { break } } } $rows = [System.Collections.Generic.List[object]]::new() $stack = [System.Collections.Generic.Stack[object]]::new() $stack.Push(@($rootPath, 0)) while ($stack.Count) { $folder, $level = $stack.Pop() $s = $stats[$folder] $relative = if ($folder -eq $rootPath) { '.' } else { $folder.Substring($rootPath.Length).TrimStart($sep) } $name = if ($folder -eq $rootPath) { $root.Name } else { Split-Path -Path $folder -Leaf } $rows.Add([pscustomobject]@{ Tree = (' ' * $level) + $name Type = 'Folder' Level = $level Files = $s.Files Folders = $s.Folders TotalFiles = $s.TotalFiles SizeMB = [math]::Round($s.Bytes / 1MB, 2) LastWriteTime = $s.LastWrite RelativePath = $relative FullName = $folder }) if ($IncludeFiles -and $filesByFolder.ContainsKey($folder)) { foreach ($file in ($filesByFolder[$folder] | Sort-Object Name)) { $rows.Add([pscustomobject]@{ Tree = (' ' * ($level + 1)) + $file.Name Type = 'File' Level = $level + 1 Files = $null Folders = $null TotalFiles = $null SizeMB = [math]::Round($file.Length / 1MB, 2) LastWriteTime = $file.LastWriteTime RelativePath = $file.FullName.Substring($rootPath.Length).TrimStart($sep) FullName = $file.FullName }) } } if ($level -lt $Depth) { # Push in reverse so the stack pops them alphabetically. foreach ($child in ($children[$folder] | Sort-Object -Descending)) { $stack.Push(@($child, ($level + 1))) } } } if ($CsvPath) { try { $rows | Select-Object Type, Level, RelativePath, Files, Folders, TotalFiles, SizeMB, LastWriteTime, FullName | Export-Csv -LiteralPath $CsvPath -NoTypeInformation -Encoding utf8 -ErrorAction Stop Write-Verbose "Saved $($rows.Count) row(s) to $CsvPath" } catch { Write-Warning "Couldn't write $CsvPath : $($_.Exception.Message)" } } foreach ($row in $rows) { $row | Add-Member -MemberType MemberSet -Name PSStandardMembers -Value $standardMembers -PassThru } }