TB

MoppleIT Tech Blog

Welcome to my personal blog where I share thoughts, ideas, and experiences.

Safe Directory Changes in PowerShell: Push-Location, Pop-Location, and Predictable Cleanup

Changing directories in a script sounds simple until a failure mid-run leaves you stranded in an unexpected location, logs point at the wrong path, or your build agent writes artifacts to the workspace root. The fix is straightforward and robust: validate the target, push into it, do your work, then always pop back out. In this post, youll learn a safe pattern built on Push-Location/Pop-Location, with fast-fail validation and clear, absolute-path logging via Resolve-Path.

The Safe Directory Change Pattern

This pattern gives you a predictable workflow and durable cleanup even when exceptions occur. You will:

  • Validate the target directory and fail fast if its missing.
  • Use Push-Location to enter the directory.
  • Wrap your work in try/finally so Pop-Location runs no matter what.
  • Log with Resolve-Path so reviewers see definitive, absolute locations.

Baseline example

$target = 'C:\data\reports'
if (-not (Test-Path -Path $target -PathType Container)) { throw ('Missing directory: ' + $target) }

Push-Location -Path $target
try {
  $files = Get-ChildItem -File
  $out = Join-Path -Path (Get-Location) -ChildPath 'summary.txt'
  ('Count={0} When={1}' -f $files.Count, (Get-Date -Format 'u')) |
    Out-File -FilePath $out -Encoding utf8
  Write-Host ('Saved -> {0}' -f (Resolve-Path -Path $out))
} finally {
  Pop-Location
}

What this buys you:

  • Fewer path bugs: Work happens in a known place and logs show absolute locations.
  • Predictable cleanup: Pop-Location restores the previous directory even if a command throws.
  • Safer runs: Fast-fail prevents writing to the wrong place or creating paths implicitly.
  • Clearer logs: Resolve-Path removes ambiguity from relative paths.

Why Push-Location/Pop-Location beats Set-Location

Set-Location changes your current location and leaves it to you to remember to restore it. Thats fragile under error conditions and impossible to guarantee across early returns unless you uniformly use try/finally. By contrast, Push-Location puts the current location on a stack, and Pop-Location reliably restores it. This is especially important in automation and CI/CD, where scripts run unattended and must self-heal from partial failures.

Anti-pattern: naked Set-Location

# Avoid this pattern
Set-Location .\output
# ...work happens...
# Oops: exception occurs here, and we never restore the original location.

Preferred pattern: location stack + finally

Push-Location .\output
try {
  # ...work happens...
}
finally {
  Pop-Location
}

The stack-based approach is composable. Nested functions can safely Push-Location/Pop-Location without interfering with each other because each push has a matching pop.

Hardening the basics for real-world use

1) Validate and fail fast

Never assume a path exists. Explicitly check the directory and stop immediately if it doesnt. In build servers or ephemeral containers, missing mounts and incorrect workspace paths are common culprits.

$target = 'C:\data\reports'
if (-not (Test-Path -LiteralPath $target -PathType Container)) {
  throw "Missing directory: $target"
}

Tip: use -LiteralPath when paths might contain characters like square brackets or asterisks so theyre not treated as wildcards.

2) Always clean up with try/finally

Even with robust commands, surprises happen: access denied, out-of-disk, or transient network hiccups on UNC shares. finally ensures you get home no matter what.

Push-Location -LiteralPath $target
try {
  # do work
}
finally {
  Pop-Location
}

3) Log absolute locations with Resolve-Path

Reviewers and future you should not have to guess where files landed. Convert to an absolute path before logging or returning values.

$out = Join-Path -Path (Get-Location) -ChildPath 'summary.txt'
'Hello' | Out-File -FilePath $out -Encoding utf8 -NoNewline
$abs = Resolve-Path -LiteralPath $out
Write-Host ("Saved -> {0}" -f $abs)

Prefer Write-Information or structured output in automation contexts to keep your logs machine- and human-friendly. Write-Host is fine for interactive scripts, but it bypasses standard streams.

Write-Information ("Saved -> {0}" -f (Resolve-Path -LiteralPath $out)) -InformationAction Continue

4) Make it reusable: a Use-Location helper

Wrap the pattern in a utility so the rest of your code stays focused on the work.

function Use-Location {
  [CmdletBinding()]
  param(
    [Parameter(Mandatory)] [string] $Path,
    [Parameter(Mandatory)] [ScriptBlock] $Script
  )

  if (-not (Test-Path -LiteralPath $Path -PathType Container)) {
    throw "Missing directory: $Path"
  }

  Push-Location -LiteralPath $Path
  try {
    & $Script
  }
  finally {
    Pop-Location
  }
}

# Example
Use-Location -Path 'C:\data\reports' -Script {
  $files = Get-ChildItem -File
  $out = Join-Path (Get-Location) 'summary.txt'
  ('Count={0} When={1}' -f $files.Count, (Get-Date -Format 'u')) |
    Out-File -FilePath $out -Encoding utf8
  Write-Information ("Saved -> {0}" -f (Resolve-Path $out))
}

DevOps-friendly practices

CI/CD pipelines and containerized agents

  • Workspace safety: Always reference your workspace via an explicit variable (for example, Jenkins env.WORKSPACE, GitHub Actions $env:GITHUB_WORKSPACE, Azure Pipelines $(Build.SourcesDirectory)) and validate it with Test-Path.
  • Ephemeral filesystems: In containers, volumes may not mount as expected; fail fast rather than writing inside the images root filesystem.
  • Deterministic logs: Emit absolute paths so artifact publishing, archiving, and diagnostics are straightforward.
$workspace = $env:GITHUB_WORKSPACE
if (-not (Test-Path -LiteralPath $workspace -PathType Container)) {
  throw "Workspace missing: $workspace"
}

Use-Location -Path $workspace -Script {
  $artifacts = 'out'
  if (-not (Test-Path -LiteralPath $artifacts)) { New-Item -ItemType Directory -Path $artifacts | Out-Null }

  Push-Location -LiteralPath $artifacts
  try {
    'build result' | Set-Content -Path 'result.txt' -Encoding utf8
    $resultPath = Resolve-Path 'result.txt'
    Write-Output ("Artifact -> {0}" -f $resultPath)
  }
  finally { Pop-Location }
}

Security and correctness tips

  • Prefer -LiteralPath: Avoid accidental wildcard expansion in user-supplied paths.
  • Check permissions early: Use Test-Path to confirm existence, then try a benign write (or [IO.Directory]::EnumerateFileSystemEntries()) if you must verify access ahead of time.
  • Avoid global side effects: Encapsulate directory changes within functions and always use Push-Location/Pop-Location instead of setting $PWD globally.
  • Handle non-filesystem providers: Push-Location works across providers (like registry); still validate with Test-Path and be explicit about the provider prefix (for example, HKLM:\).

Troubleshooting and pitfalls

Symptom: "Saved -> .\file.txt" appears in logs

Youre logging relative paths. Run through Resolve-Path at log time:

$abs = Resolve-Path -LiteralPath $out
Write-Output ("Saved -> {0}" -f $abs)

Symptom: Paths with brackets fail

Use -LiteralPath across Test-Path, Get-ChildItem, and Resolve-Path to disable wildcard parsing.

$path = 'C:\data\reports\release[2025]'
Test-Path -LiteralPath $path -PathType Container

Symptom: Location not restored after error

Verify that Pop-Location is in a finally block and that no return statements appear before it. If you must return data, capture it in a variable and return after the finally executes.

Push-Location src
try {
  $content = Get-Content .\app.ps1 -Raw
}
finally {
  Pop-Location
}
$content

Putting it all together

Heres a consolidated template you can drop into your scripts:

param(
  [Parameter()] [string] $Target = 'C:\data\reports'
)

if (-not (Test-Path -LiteralPath $Target -PathType Container)) {
  throw "Missing directory: $Target"
}

Push-Location -LiteralPath $Target
try {
  $files = Get-ChildItem -File -LiteralPath .
  $out = Join-Path -Path (Get-Location) -ChildPath 'summary.txt'
  ('Count={0} When={1}' -f $files.Count, (Get-Date -Format 'u')) |
    Out-File -FilePath $out -Encoding utf8
  $abs = Resolve-Path -LiteralPath $out
  Write-Information ("Saved -> {0}" -f $abs) -InformationAction Continue
}
finally {
  Pop-Location
}

With this pattern in place, your scripts are resilient and reviewers can easily trace where artifacts landed. It scales cleanly across nested functions, parallel jobs, and CI agents, and it hardens your automation against the most common directory-related bugs.

Key takeaways

  • Validate paths and fail fast.
  • Use Push-Location before work and Pop-Location in finally to guarantee cleanup.
  • Log absolute paths with Resolve-Path for auditability and easier debugging.
  • Encapsulate the pattern in helpers for consistency across scripts and teams.
← All Posts Home →