TB

MoppleIT Tech Blog

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

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

Directory drift is a silent script killer. A function that quietly changes your working directory can derail the rest of your pipeline, corrupt outputs, or write files to unexpected places. The fix is simple and powerful: always bracket directory changes, resolve absolute paths, and log your intent. In this post, you’ll learn a repeatable pattern that keeps your PowerShell scripts safe, predictable, and easy to troubleshoot.

Why Predictable Directory Changes Matter

PowerShell’s current location is global to a session. Any Set-Location (cd) you do in one spot affects all subsequent commands. In larger scripts and CI/CD pipelines, this creates common failure modes:

  • Wandering scripts: Nested functions change directories and never return, breaking callers later in the run.
  • Non-reproducible failures: Tests pass locally where the path exists, fail in a container or agent where it doesn’t.
  • Data loss or leakage: Outputs or temp files end up in the wrong folder, get committed by accident, or overwrite unrelated files.
  • Unreadable logs: Without clear logging, it’s hard to trace where you went and why.

The solution is to treat directory changes like a resource: acquire, use, release. PowerShell gives you first-class primitives for this with Push-Location and Pop-Location.

The Core Pattern: Push-Location, try/finally, Pop-Location

Use Push-Location before changing directories. Do your work in a try block. Put Pop-Location into the finally block so you always return home, even on failure. Resolve and log absolute paths up front to make intent obvious in logs.

Annotated example

# Stop on errors so try/catch actually catches
$ErrorActionPreference = 'Stop'

$root = Join-Path -Path (Get-Location) -ChildPath 'work'
New-Item -ItemType Directory -Path $root -Force | Out-Null

$sub = Join-Path -Path $root -ChildPath (Get-Date -Format 'yyyyMMdd')
New-Item -ItemType Directory -Path $sub -Force | Out-Null

# Resolve to an absolute path; avoid surprises with relative paths
$dest = Resolve-Path -Path $sub
Write-Host ('-> Enter {0}' -f $dest)

Push-Location -Path $dest
try {
  'ok' | Out-File -FilePath 'status.txt' -Encoding utf8
  Get-ChildItem -File | Select-Object -First 3 Name
}
catch {
  Write-Warning ('Error: {0}' -f $_.Exception.Message)
}
finally {
  Pop-Location
  Write-Host ('<- Back to {0}' -f (Get-Location))
}

Key points:

  • Absolute paths: Resolve-Path normalizes the target and protects you from relative-path confusion.
  • Structured logging: Print an entry arrow before Push-Location and an exit arrow in finally. Your logs now read like a clean stack trace.
  • Exception safety: finally guarantees you always return to the original directory.

Safer path resolution

Prefer -LiteralPath when input might include wildcard characters like [ or *, and add -ErrorAction Stop so failures are caught:

$dest = Resolve-Path -LiteralPath $sub -ErrorAction Stop

When you must work relative to the script’s location (not where PowerShell was launched), anchor to $PSScriptRoot for reliability:

# Inside a script or module
$assets = Join-Path -Path $PSScriptRoot -ChildPath 'assets'

Avoid common pitfalls

  • Don’t use Set-Location alone in shared code unless you also restore the original location. Wrap it with Push-Location/Pop-Location or the helper below.
  • Don’t assume your directory exists. Create it or fail clearly.
  • Don’t rely on implicit provider changes. PowerShell can navigate providers (e.g., file system, registry). Log the provider and path you enter.

Production-Ready Extras: A Helper, Logging, and Pipelines

A reusable helper: Invoke-InDirectory

Capture the pattern in a tiny utility so your scripts stay tidy and consistent:

function Invoke-InDirectory {
  [CmdletBinding()]
  param(
    [Parameter(Mandatory)] [string] $Path,
    [Parameter(Mandatory)] [scriptblock] $Script,
    [switch] $Create
  )

  # Normalize and optionally create the target
  if ($Create -and -not (Test-Path -LiteralPath $Path)) {
    New-Item -ItemType Directory -Path $Path -Force | Out-Null
  }
  $resolved = Resolve-Path -LiteralPath $Path -ErrorAction Stop

  Write-Host ('-> Enter {0}' -f $resolved)
  Push-Location -Path $resolved
  try {
    & $Script
  }
  finally {
    Pop-Location
    Write-Host ('<- Back to {0}' -f (Get-Location))
  }
}

# Usage example
Invoke-InDirectory -Path (Join-Path $PSScriptRoot 'build') -Create -Script {
  'building...' | Out-File -FilePath 'status.txt' -Encoding utf8
  Get-ChildItem -File | Select-Object -First 5 Name
}

This encapsulation makes it nearly impossible to forget to restore the directory, and the log lines remain uniform across your codebase.

Better logs and observability

  • Log intent before action: print the absolute path before Push-Location.
  • Echo the return path: after Pop-Location, show where you ended up.
  • Optional transcript: For long-running jobs, use Start-Transcript and Stop-Transcript to capture everything in one file.
$log = Join-Path $env:TEMP ('build-{0}.log' -f (Get-Date -Format 'yyyyMMdd-HHmmss'))
Start-Transcript -Path $log -Force
try { 
  Invoke-InDirectory -Path (Join-Path $PSScriptRoot 'dist') -Create -Script {
    # work
  }
}
finally { Stop-Transcript }

CI/CD and containers

In pipelines, a stray directory change can break subsequent steps. Use the pattern for each step that navigates:

# Example: GitHub Actions PowerShell step
# $env:GITHUB_WORKSPACE is the repo root
$root = $env:GITHUB_WORKSPACE
Invoke-InDirectory -Path (Join-Path $root 'src') -Script {
  # Build
  dotnet build --configuration Release
}

Invoke-InDirectory -Path (Join-Path $root 'artifacts') -Create -Script {
  # Package outputs
  Copy-Item -Path (Join-Path $root 'src' 'bin' 'Release' '*') -Destination . -Recurse
}

In containers, the working directory may be set by the image (WORKDIR). Still, push/pop inside scripts keeps your layers reproducible and your logs clean. When running cross-platform on PowerShell 7, this pattern works the same on Windows, Linux, and macOS.

Security and correctness tips

  • Validate paths: Use -LiteralPath to avoid wildcard expansion from user input.
  • Fail fast: Set $ErrorActionPreference = 'Stop' in automation contexts so that try/catch actually handles failures.
  • Guard against unexpected providers: If you must ensure file system only, verify (Get-Location).Drive.Provider.Name -eq 'FileSystem' before file operations.
  • Avoid race conditions: When creating directories in parallel steps, include unique suffixes or use job-specific output folders.

Performance and reliability pointers

  • Batch your file operations: Minimize directory switches; do all work for a target directory inside one Invoke-InDirectory block.
  • Use Join-Path consistently: Avoid string concatenation for paths; it’s error-prone across platforms.
  • Prefer Out-File -Encoding utf8 without BOM for portable text artifacts.

Testing the pattern

Even without a full test framework, you can sanity-check your script’s directory behavior:

$start = Get-Location
Invoke-InDirectory -Path (Join-Path $env:TEMP 'demo') -Create -Script {
  'demo' | Out-File -FilePath 'touch.txt'
}
$end = Get-Location
if ($start.Path -ne $end.Path) { throw 'Working directory not restored!' }

Test-Path (Join-Path $env:TEMP 'demo' 'touch.txt') | Should -BeTrue # If using Pester

This catches regressions where someone accidentally removes the finally block or adds a stray Set-Location.

Putting it all together

Combine the core pattern with a few conventions and you’ll get:

  • Fewer path bugs from missing restores and relative path confusion.
  • Safer runs with explicit path validation and -LiteralPath.
  • Cleaner logs with clear enter/exit markers and absolute paths.
  • Predictable exits so later steps and scripts behave consistently.

Keep your scripts from wandering: Push-Location before changing paths, Pop-Location in finally to return home, resolve absolute paths, and log your intent. Build steadier scripts in PowerShell.

Further reading: PowerShell Advanced CookBook → https://www.amazon.com/PowerShell-Advanced-Cookbook-scripting-advanced-ebook/dp/B0D5CPP2CQ/

← All Posts Home →