Find files and folders - 夜莺博客

Find files and folders

Original article: Find files and folders — theDXT (Daniel Keer)

Recently I needed to find all files and folders containing two spaces, normally I would use something like a file renamer but in this case for various reasons I just needed to generate a list. Eventually I ended up figuring out a PowerShell command to do it.

Here’s the code I used to do it

get-childitem -recurse | where-object {$_ -match '  '} | select-object FullName | export-csv -notypeinformation -delimiter '|' C:\file.csv

The same code can be used to find anything. Just replace the two spaces with whatever you need to find.

Background: why generate a list instead of renaming in place

A file renamer is the obvious tool when you already know exactly what you want to change. It is the wrong tool when the set of files is unknown, because a rename is destructive: once it runs you cannot diff the result against the original state, and if the pattern was too broad you have to reverse it from memory. Generating a list first is the safer order of operations, and it costs almost nothing to produce.

This matters more than it sounds on a file server. Two consecutive spaces in a path are a classic source of downstream weirdness: they survive copy operations invisibly, they turn into %20%20 when a path gets embedded in a URL, they look like a single space in Explorer, and they break shell one-liners that split on whitespace. The same class of problem shows up with a trailing space before the file extension, with non-ASCII lookalike characters pasted in from a chat client, and with case differences on a case-sensitive target such as Linux or S3.

Other cases where a list beats an in-place edit: chasing down what is consuming a full disk, finding everything older than a retention window before an archive run, handed-off audits where a colleague needs the raw evidence, and any situation where a human has to approve the change set before it happens.

Prerequisites

  • Windows PowerShell 5.1 (ships with Windows 10 and Windows Server 2016 and later) or PowerShell 7+. Check with $PSVersionTable.PSVersion.
  • Read access to the whole tree you want to scan. If protected folders such as C:\System Volume Information are in scope, run the session elevated — otherwise expect a wall of access-denied noise.
  • An output location that exists. Export-Csv will not create a missing parent folder for you.
  • If you want the report to survive being opened on another machine, write it as UTF-8, and remember that Windows PowerShell 5.1 writes a byte-order mark while PowerShell 7 does not.
  • For very deep trees, check whether long path support is enabled. The registry value is HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem\LongPathsEnabled.

What the command actually does

Breaking the one-liner into its four stages makes it easy to adapt:

get-childitem -recurse Walks the current directory and everything beneath it, emitting a FileInfo or DirectoryInfo object per entry. Without -Force it skips hidden and system items. Note that -Recurse on a large network share is the slow part of this pipeline.
where-object {$_ -match ' '} Filters by stringifying each object and testing it against a regular expression. For these objects the string form is the path, so the test covers the folder names above the file as well as the leaf name. This is also why the command returns folders as well as files.
select-object FullName Reduces each surviving object to a single property, so the export is a clean one-column file rather than a dump of every attribute.
export-csv -notypeinformation -delimiter '|' Writes the report. -NoTypeInformation drops the leading #TYPE line that otherwise confuses non-PowerShell consumers; -Delimiter switches away from the default comma.

Two details are worth flagging before you copy this into production. First, -match is a regular expression operator, not a literal compare: because two spaces contain no metacharacters the original works, but the moment you look for something like report (final) or v1.2 the parentheses and the dot change the meaning of the pattern. Second, -match tests the string form of the object, which for path objects is the full path — so a spotless file name inside a badly named folder still shows up. If you want to test the file name only, say so explicitly.

Step by step

Step 1 — open an elevated PowerShell

Start Windows PowerShell as Administrator so that protected subfolders do not silently drop out of the result set, then move to a working directory you can write to.

$PSVersionTable.PSVersion
Set-Location C:\

Step 2 — set the root, the output file and the filter

$root   = 'D:\Data'
$report = 'C:\Temp\double-space.csv'

New-Item -ItemType Directory -Force -Path (Split-Path $report) | Out-Null

Step 3 — run the scan

Get-ChildItem -Path $root -Recurse -Force -ErrorAction SilentlyContinue |
    Where-Object { $_.Name -like '*  *' } |
    Select-Object FullName, Length, LastWriteTime |
    Export-Csv -NoTypeInformation -Delimiter '|' -Encoding UTF8 -Path $report

Three deliberate changes from the original: -Path so the scan is reproducible instead of depending on the current directory, -ErrorAction SilentlyContinue so one unreadable folder does not abort the whole run, and $_.Name -like so the match is literal and limited to the item name. Add Length and LastWriteTime and you get a report you can sort without a second pass over the filesystem.

Step 4 — count and eyeball the result

$rows = Import-Csv -Path $report -Delimiter '|'
$rows.Count
$rows | Select-Object -First 10 | Format-Table -AutoSize

Step 5 — hand the list to whatever comes next

$rows.FullName |
    ForEach-Object { $_ } |
    Set-Content -Path 'C:\Temp\double-space.txt' -Encoding UTF8

A plain-text version is often the most useful artefact: it can be pasted into a ticket, fed to a diff, or read line by line on a machine that has no PowerShell at all.

Variations: the same pipeline finds anything

Names containing two spaces $_.Name -like '* *'
Files only, ignoring folders add -File to Get-ChildItem
Folders only add -Directory instead
Anything larger than 100 MB $_.Length -gt 100MB
Modified in the last 7 days $_.LastWriteTime -gt (Get-Date).AddDays(-7)
Only log and temp files $_.Extension -in '.log','.tmp'
Non-ASCII or lookalike characters in the name $_.Name -match '[^\x00-\x7F]'
Names beginning with an ISO date $_.Name -match '^\d{4}-\d{2}-\d{2}'
A trailing space before the extension $_.BaseName -like '* '
Hidden and system items included add -Force
Path longer than 200 characters $_.FullName.Length -gt 200

One performance note. For simple leaf-name patterns the provider-level -Filter parameter is dramatically faster than piping everything through Where-Object, because the filtering happens inside the filesystem rather than in PowerShell:

Get-ChildItem -Path 'D:\Logs' -Recurse -File -Filter '*.log' |
    Where-Object { $_.Length -gt 50MB }

The catch is that -Filter understands only the filesystem's own wildcards (* and ?), never regular expressions, and it only ever looks at the last path element. Combine the two: -Filter to cut the volume, Where-Object for the logic it cannot express.

Verify the results

A list is only worth acting on if you trust it. Four checks that catch almost every mistake:

  1. Count it twice, two different ways. Compare the number of rows in the report against an independent walk of the tree. If the totals disagree by more than the number of directories you expected to match, your filter is doing something other than what you think.
    (Get-ChildItem $root -Recurse -Force -ErrorAction SilentlyContinue).Count
    (Import-Csv -Path $report -Delimiter '|').Count
    
  2. Cross-check with a completely different engine. cmd.exe does not share the PowerShell pipeline, so it is a genuinely independent second opinion for a simple pattern:
    cmd /c dir /s /b "D:\Data\*  *"
    
  3. Open three rows by hand. Pick the first, the last and one from the middle, and confirm the name really does contain two spaces. This catches the classic failure mode where the pattern matched something adjacent to what you meant.
    $rows | Get-Random -Count 3 | ForEach-Object { Invoke-Item (Split-Path $_.FullName) }
    
  4. Confirm the file survived the round trip. Re-import it and check the first line, which also proves the encoding and delimiter are usable outside PowerShell:
    Get-Content -Path $report -TotalCount 3
    

If you are about to rename, do not skip step 3. Run the rename against a copy of the list with Rename-Item -WhatIf first, review the proposed pairs, and only then let it loose.

The same job on Linux and macOS

The GNU find utility does the same work with a different vocabulary:

find /data -depth -name '*  *' -print
find /data -type f -size +100M -print
find /data -type f -mmin -10080 -print
find /data -type f -name '*.log' -newermt '2026-09-01' -print

Two portability traps. The -printf action does not exist in the BSD find that ships with macOS, and -newermt is a GNU extension, so on a Mac use -mtime -7 for a seven-day window and stat -f instead of stat -c. To emit a report with sizes without -printf:

find /data -name '*  *' -exec stat -f '%z %N' {} \;   # macOS
find /data -name '*  *' -exec stat -c '%s %n' {} \;   # Linux

For names that may contain newlines, use a NUL-separated pipeline so nothing gets mangled. This is bash/zsh syntax and will not run in sh:

find /data -name '*  *' -print0 | while IFS= read -r -d '' f; do printf '%s\n' "$f"; done

And if ripgrep is installed, its file walker is usually the fastest way to enumerate a tree for a simple name pattern:

rg --files /data | grep '  '

Common problems

The report is empty even though I know the names contain two spaces. The two characters might not be spaces. Editors and chat clients happily paste a non-breaking space (U+00A0), an ideographic space (U+3000) or a tab. Widen the pattern to catch them all:

Where-Object { $_.Name -match '[  \t\u00A0\u3000]{2,}' }

The pattern matched far too much. -match is a regex. A literal dot matches every character, and parentheses, square brackets and a plus sign change the meaning of the expression. For a literal comparison use the wildcard operator, or escape the input:

Where-Object { $_.Name -like '*v1.2*' }                     # literal-ish
Where-Object { $_.Name -match [regex]::Escape('v1.2') }     # explicit

“Access to the path … is denied” scrolls past. Either run elevated, or accept the noise deliberately with -ErrorAction SilentlyContinue and record what you skipped by capturing errors to a variable with -ErrorVariable when you need an audit trail.

Excel opens the report as a single column. That is expected for a pipe-delimited file, because Excel uses the local list separator when it guesses. Either export with -Delimiter ',' — Export-Csv quotes fields containing the delimiter, so commas inside file names are safe — or import through Data, From Text and specify the pipe.

The first line starts with . Windows PowerShell 5.1 writes UTF-8 with a byte-order mark. Switch to -Encoding UTF8NoBOM on PowerShell 7, or re-encode with [System.IO.File]::WriteAllLines().

The scan takes hours on a network share. Each entry is a round trip. Do the enumeration on the server hosting the data, or at least run it in a session on the same subnet, and use -Filter instead of Where-Object for the coarse pass. robocopy /L is another good option because it can log matching files without transferring anything.

“The specified path, file name, or both are too long.” The default 260-character limit applies unless long paths are enabled in the registry and the application manifest declares awareness. On older systems, shorten the search root and walk the tree in chunks instead.

Takeaway

The pattern is the whole idea: enumerate, filter, project, export — and keep the output. The original one-liner already does all four, and the only real decisions are how precisely you write the filter and whether you look at the result before you act on it. Point it at double spaces, oversized files, stale archives or a single bad extension, and the same five lines answer the question.

Related reading