TB

MoppleIT Tech Blog

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

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

Ever been stranded in the wrong folder after a script fails, only to discover later that your relative paths silently wrote files somewhere unexpected? You can fix this class of bugs for good by scoping directory changes with Push-Location and Pop-Location, constructing paths with Join-Path (never string-concatenating slashes), and validating output with Resolve-Path. The result: fewer directory bugs, safer scripts, predictable paths, and cleaner exits.

The core pattern: Push-Location in try; Pop-Location in finally

Use a try/catch/finally block to guarantee your working directory is restored even if an error occurs. Build paths with Join-Path and confirm file locations with Resolve-Path to log what was written.

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

Push-Location -Path $root
try {
  $stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
  $file  = Join-Path -Path (Get-Location) -ChildPath ("summary_{0}.txt" -f $stamp)
  'OK' | Out-File -FilePath $file -Encoding utf8
  Write-Host ("Saved -> {0}" -f (Resolve-Path -Path $file))
} catch {
  Write-Warning ("Work failed: {0}" -f $_.Exception.Message)
} finally {
  Pop-Location
}

What this buys you:

  • Predictable and reversible location changes: Pop-Location runs even if the try block throws.
  • Correct path composition: Join-Path handles separators and edges (no trailing or double slashes).
  • Verifiable outputs: Resolve-Path confirms the real, absolute file location you can log and trace later.

Harden it further

Make errors truly catchable and your logs more precise. In PowerShell, some cmdlets write non-terminating errors by default. Add -ErrorAction Stop to operations that must fail fast, and prefer -LiteralPath when you don t want wildcard expansion.

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

Push-Location -Path $root
try {
  $stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
  $file  = Join-Path -Path (Get-Location) -ChildPath ("summary_{0}.txt" -f $stamp)

  'OK' | Out-File -FilePath $file -Encoding utf8 -ErrorAction Stop

  $resolved = Resolve-Path -LiteralPath $file -ErrorAction Stop
  Write-Host ("Saved -> {0}" -f $resolved)
} catch {
  Write-Warning ("Work failed: {0}" -f $_.Exception.Message)
} finally {
  Pop-Location
}

Tip: Anchor relative paths to the script27s directory, not the caller27s. Use $PSScriptRoot (in script/module scope) as your base:

$root = Join-Path -Path $PSScriptRoot -ChildPath 'reports'

Production-grade patterns and utilities

A reusable Invoke-InDirectory helper

Package the try/Pop pattern into a helper that scopes work to a location. This avoids repeating Push-Location/Pop-Location throughout your scripts.

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

  Push-Location -Path $Path
  try {
    & $ScriptBlock
  } finally {
    Pop-Location
  }
}

# Usage
$target = Join-Path -Path $PSScriptRoot -ChildPath 'reports'
New-Item -ItemType Directory -Path $target -Force | Out-Null

Invoke-InDirectory -Path $target -ScriptBlock {
  $stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
  $file  = Join-Path -Path (Get-Location) -ChildPath ("summary_{0}.txt" -f $stamp)
  'OK' | Out-File -FilePath $file -Encoding utf8 -ErrorAction Stop
  Write-Host ("Saved -> {0}" -f (Resolve-Path -LiteralPath $file))
}

Because the helper always calls Pop-Location in a finally block, your calling code never gets stuck in the wrong folder.

Safely handling temp and network paths

  • Temporary work folders: Build unique directories under the OS temp path so parallel runs don27t collide.
$runId = Get-Date -Format 'yyyyMMdd-HHmmss'
$tempRoot = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath ("job-" + $runId)
New-Item -ItemType Directory -Path $tempRoot -Force -ErrorAction Stop | Out-Null

Invoke-InDirectory -Path $tempRoot -ScriptBlock {
  # Do isolated work here
}
  • UNC shares: For long operations on network paths, consider mapping a transient PSDrive for readability and reliability, then scope with Push-Location.
$drive = 'R'
$root  = '\\fileserver\reports'

# Map (non-persistent) drive for this session
New-PSDrive -Name $drive -PSProvider FileSystem -Root $root -ErrorAction Stop | Out-Null

Invoke-InDirectory -Path ("{0}:" -f $drive) -ScriptBlock {
  # Work within R:\
}

# Optional: Remove-PSDrive -Name $drive

Make failures visible and recoverable

  • Prefer cmdlets with -ErrorAction Stop, or temporarily set $ErrorActionPreference = 'Stop' around critical sections you control.
  • Log absolute paths using Resolve-Path so build logs are actionable.
  • Use -LiteralPath when paths may contain [, ], *, or ? to avoid wildcard surprises.

Real-world use cases and best practices

CI/CD pipelines and build scripts

Build systems often run many steps that assume a particular working directory. By scoping each step with Push-Location/Pop-Location (or using the Invoke-InDirectory helper), each step becomes independent and resilient to failures in previous steps. This reduces flaky builds and makes logs consistent.

Scheduled tasks and automation runners

Scheduled tasks may start in C:\Windows\System32 or another service-defined folder. Always resolve your script27s base via $PSScriptRoot, create an explicit working directory, and scope your changes so the task is deterministic every run.

Safer file operations

  • Never concatenate slashes: Join-Path $base 'subdir' 'file.txt' avoids $base + '\\subdir\\file.txt' bugs across platforms and PSDrive roots.
  • Validate before write: Test-Path -LiteralPath $dir and New-Item -ItemType Directory -Force ensure the target exists.
  • Confirm what you saved: Resolve-Path prints canonical paths in logs, which is invaluable during incident reviews.
  • Keep changes local: Don27t rely on callers to restore state. Your function/script should leave the session as it found it.
  • Parallelism friendly: Location is per-runspace; scoped Push/Pop plays well with jobs and parallel loops.

Putting it all together

function Write-Report {
  [CmdletBinding()]
  param(
    [Parameter(Mandatory)] [string] $Name
  )

  $reports = Join-Path -Path $PSScriptRoot -ChildPath 'reports'
  New-Item -ItemType Directory -Path $reports -Force -ErrorAction Stop | Out-Null

  Push-Location -Path $reports
  try {
    $stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
    $file  = Join-Path -Path (Get-Location) -ChildPath ("{0}_{1}.txt" -f $Name, $stamp)

    "Report: $Name" | Out-File -FilePath $file -Encoding utf8 -ErrorAction Stop

    $abs = Resolve-Path -LiteralPath $file -ErrorAction Stop
    Write-Host ("Saved -> {0}" -f $abs)
    return $abs
  } catch {
    Write-Warning ("Failed to write report: {0}" -f $_.Exception.Message)
    throw
  } finally {
    Pop-Location
  }
}

# Example
$path = Write-Report -Name 'summary'

What you get:

  • Fewer directory bugs
  • Safer scripts
  • Predictable paths
  • Cleaner exits

Make location changes predictable and reversible: treat the working directory like any other resource that must be acquired and released. Use Push-Location before work begins, Pop-Location in a finally block, use Join-Path to compose paths, and Resolve-Path to verify and log where your outputs actually landed. Your future self (and your CI logs) will thank you.

← All Posts Home →