TB

MoppleIT Tech Blog

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

Safer Directory Changes in PowerShell: Push-Location/Pop-Location and Join-Path Patterns

Changing the working directory inside scripts is convenient—until a run fails and leaves your shell stranded in the wrong folder. In PowerShell, the safest pattern is to push the location before you move and always pop it in a finally block. Combine that with Join-Path for explicit path building and you get predictable, cross-platform scripts that won't surprise you or your CI runners.

The Core Pattern: Push Before You Move, Pop in Finally

The simplest way to keep directory state predictable is to save the current path, enter your workspace, and guarantee a return—even when errors occur. Here is a concise, production-ready example:

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

Push-Location -Path $work
try {
  $out = Join-Path -Path (Get-Location) -ChildPath 'result.txt'
  "OK $(Get-Date -Format 'u')" | Out-File -FilePath $out -Encoding utf8
  Write-Host ("Wrote -> {0}" -f (Resolve-Path -Path $out))
} finally {
  Pop-Location
}

Write-Host ("Back -> {0}" -f (Get-Location))

Why this works well:

  • Exception-safe: finally always runs—whether your code succeeds, throws, or the script is stopped—so your session returns to the original folder.
  • Clear intent: Push-Location/Pop-Location explicitly encodes the idea of temporary context, unlike Set-Location which silently mutates global state.
  • Readable logs: Resolve-Path in logs prints a full, absolute path for auditing and troubleshooting.

Why Working Directory Safety Matters

Directory drift is subtle but costly in automation and day-to-day development:

  • CI/CD pipelines: One step changing CWD can break later steps that assume a repo root or artifact directory.
  • Background jobs and scheduled tasks: Headless contexts often start in unexpected directories. Relying on relative paths leads to writes in the wrong place.
  • Developer shells: A script that leaves you in a temp folder causes confusing subsequent commands and dirty histories.
  • Security and compliance: Accidentally writing logs or outputs to unintended locations makes auditing and cleanup harder.

Build Paths Safely with Join-Path

Avoid string-concatenating paths (e.g., "$root\sub\file")—it is brittle across platforms and error-prone with spaces or special characters. Prefer Join-Path so relative segments are explicit and provider-appropriate separators are used.

# Good: portable and explicit
$root = $PWD.Path
$outDir = Join-Path -Path $root -ChildPath 'out'
$outFile = Join-Path -Path $outDir -ChildPath 'build.log'

# Ensure the container exists
New-Item -ItemType Directory -Path $outDir -Force | Out-Null

# Write safely (avoid wildcard expansion)
"Build OK $(Get-Date -Format u)" | Set-Content -LiteralPath $outFile -Encoding utf8

Write-Host ("Log -> {0}" -f (Resolve-Path -LiteralPath $outFile))

Tips for robust path handling:

  • Prefer -LiteralPath when accepting user input to avoid wildcard expansion surprises (*, ?, [).
  • Validate containers before pushing: if (-not (Test-Path -LiteralPath $dir -PathType Container)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
  • Log absolute paths: Resolve-Path -LiteralPath yields normalized, canonical paths for reliable audit trails.
  • PS 7+ convenience: If available, Join-Path supports -AdditionalChildPath for multiple segments in one call. Otherwise, chain calls as shown.

Production Patterns You Can Reuse

1) A Reusable Helper: Use-Location

Wrap the push/pop pattern in a helper so you can express intent concisely throughout your scripts.

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

    [Parameter(Mandatory, Position=1)]
    [scriptblock]$Script
  )

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

$workspace = Join-Path -Path $PWD.Path -ChildPath 'work'
New-Item -ItemType Directory -Path $workspace -Force | Out-Null

Use-Location -Path $workspace -Script {
  $out = Join-Path -Path $PWD.Path -ChildPath 'result.txt'
  "OK $(Get-Date -Format u)" | Set-Content -LiteralPath $out -Encoding utf8
  Write-Host ("Wrote -> {0}" -f (Resolve-Path -LiteralPath $out))
}

Write-Host ("Still here -> {0}" -f $PWD.Path)

Benefits:

  • One-liner ergonomics: Express "run this block in that directory" without repeating boilerplate.
  • Safety by default: Every call returns you to the original location on success or failure.
  • Clearer intent: Future readers immediately see directory scope boundaries.

2) Isolate Nested Contexts with Named Stacks

PowerShell supports multiple named location stacks. Use -StackName to avoid collisions when multiple components push/pop independently (e.g., nested build steps, module internals).

$buildDir = Join-Path -Path $PWD.Path -ChildPath 'build'
New-Item -ItemType Directory -Path $buildDir -Force | Out-Null

Push-Location -StackName build -Path $buildDir
try {
  # ... run build tasks safely ...
}
finally {
  Pop-Location -StackName build
}

Named stacks make composition safer: each routine manages its own stack without interfering with others using the default stack.

3) Temporary Workspaces without Directory Drift

Use a temp directory as a sandbox and always return to the original location.

$tempRoot = [System.IO.Path]::GetTempPath()
$sessionDir = Join-Path -Path $tempRoot -ChildPath ([System.IO.Path]::GetRandomFileName())
New-Item -ItemType Directory -Path $sessionDir -Force | Out-Null

Push-Location -LiteralPath $sessionDir
try {
  # Work here safely...
  $out = Join-Path -Path $PWD.Path -ChildPath 'session.json'
  '{"ok":true}' | Set-Content -LiteralPath $out -Encoding utf8
  Write-Host ("Temp out -> {0}" -f (Resolve-Path -LiteralPath $out))
}
finally {
  Pop-Location
}

# Optionally cleanup (make sure you're not in the folder!)
Remove-Item -LiteralPath $sessionDir -Recurse -Force

Hardening and Troubleshooting Tips

  • Make failures throw: Use -ErrorAction Stop (or set $ErrorActionPreference = 'Stop') for file operations so exceptions trigger finally reliably.
  • Minimize Get-Location calls: Capture once ($root = $PWD.Path) and reuse for performance and clarity.
  • Prefer Push-Location over Set-Location inside functions: The latter mutates global session state unless you remember to restore it.
  • Provider awareness: Push-Location/Pop-Location work with other providers (e.g., Registry). Be explicit when switching providers to avoid confusion (Set-Location -Path HKCU:\Software).
  • Log context at boundaries: Before and after long-running steps, print (Resolve-Path .) so build logs capture where work occurred.
  • CI/CD sanity checks: At job start, assert the expected working directory and fail fast if it differs (if ((Resolve-Path .) -ne (Resolve-Path $ExpectedRoot)) { throw "Unexpected CWD" }).

Real-World Use Cases

  • Build pipelines: Enter src or build only for the duration of a step. Artifacts are written using absolute paths built via Join-Path, then you pop back to the repo root.
  • Release packaging: Stage files in a temp workspace, zip them, and guarantee you return to the starting directory even on failures.
  • Data migrations: Iterate tenant folders safely: push into each tenant directory, emit logs with absolute paths, pop back before the next tenant.
  • Editor tasks and pre-commit hooks: Don’t leave developers stranded in .git or tools directories after helper scripts run.

Common Pitfalls to Avoid

  • Forgetting to pop: Always use try/finally. Don’t rely on return paths or conditional branches.
  • Assuming relative paths: Use Join-Path with a known base ($PWD.Path or a captured $root) instead of relying on implicit CWD.
  • Wildcard surprises: If a user inputs a path like reports[2025], non-literal parameters may expand it. Prefer -LiteralPath when dealing with external input.
  • Mixed separators: Avoid manual \ or /. Let Join-Path normalize for the platform.

Bottom line: treat the working directory as a scoped resource. Push it when you need a temporary context, build paths explicitly with Join-Path, and pop it in a finally block. You’ll get fewer surprises, safer context, predictable runs, and cleaner logs—whether you’re running locally, in CI, or inside containers.

← All Posts Home →