TB

MoppleIT Tech Blog

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

Safe Working Directory Changes in PowerShell: Scope with Push-Location and Predictable Cleanup

Changing the working directory inside a script seems harmless—until a failed run leaves your shell or job in the wrong folder, breaking relative paths and surprising anything that runs next. In production, that can mean failed deployments, corrupted outputs, or accidental writes to the wrong location. The fix is straightforward: resolve the target path, scope the change with Push-Location, and guarantee cleanup with a finally block. In this post, you’ll learn a reliable pattern you can drop into any PowerShell script to keep directory changes safe, predictable, and self-cleaning.

Why Scoping Directory Changes Matters

The hidden risk of Set-Location

Set-Location (cd) changes global process state. If your script throws, returns early, or gets interrupted, the location stays changed and every subsequent relative path is resolved against the wrong directory. Common failure modes include:

  • CI jobs mysteriously failing because a prior step left the location elsewhere
  • Scheduled tasks writing outputs to C:\Windows\System32 (the default working directory for services)
  • Accidentally operating on the wrong repository subfolder during local debugging

The simple rule of three

To avoid these issues, adopt this pattern whenever you change location:

  1. Resolve the path up front (absolute, validated, and provider-safe).
  2. Scope the change with Push-Location.
  3. Guarantee cleanup in finally with Pop-Location, even on errors.

The Safe Pattern: Resolve, Push-Location, Try/Catch/Finally, Pop

Baseline snippet

Here’s a minimal, safe pattern that resolves the target and reliably returns to the original directory:

param([string]$Path = './data')

$target = Resolve-Path -Path $Path -ErrorAction Stop
Write-Host ('Enter -> {0}' -f $target)

Push-Location -Path $target
try {
  # Work is safely scoped to $target
  $files = Get-ChildItem -Path . -File -ErrorAction Stop
  $files | Select-Object -First 3 Name, Length
} catch {
  Write-Warning ('Failed in {0}: {1}' -f (Get-Location), $_.Exception.Message)
  throw
} finally {
  Pop-Location
  Write-Host ('Back -> {0}' -f (Get-Location))
}

Why this works:

  • Resolve-Path ensures you’re operating on a valid, absolute path. If the folder doesn’t exist, the script stops immediately and predictably.
  • Push-Location changes location but stores the previous one on a stack.
  • finally executes even if an error occurs or you rethrow, ensuring Pop-Location always runs.
  • Logging before and after provides auditability for where work was done.

Make it reusable: Use-Location helper

Wrap the pattern in a reusable function so you can call it around any script block:

function Use-Location {
  [CmdletBinding(SupportsShouldProcess=$true)]
  param(
    [Parameter(Mandatory)][string]$Path,
    [Parameter(Mandatory)][scriptblock]$Script,
    [switch]$Literal
  )

  $resolveParams = @{ ErrorAction = 'Stop' }
  if ($Literal) { $resolveParams['LiteralPath'] = $Path } else { $resolveParams['Path'] = $Path }

  $resolved = Resolve-Path @resolveParams
  $dir = (Get-Item -LiteralPath $resolved -ErrorAction Stop)
  if (-not $dir.PSIsContainer) {
    throw "Path '$Path' is not a directory."
  }

  if ($PSCmdlet.ShouldProcess($resolved, 'Push-Location')) {
    Write-Information "Enter -> $resolved"
    Push-Location -Path $resolved
    try {
      & $Script
    } finally {
      Pop-Location
      Write-Information ("Back -> {0}" -f (Get-Location))
    }
  }
}

# Example usage
Use-Location -Path './data' -Script {
  Get-ChildItem -File | Sort-Object Length -Descending | Select-Object -First 5 Name, Length
} -Verbose

Highlights:

  • SupportsShouldProcess lets you run with -WhatIf or -Confirm to preview changes in sensitive jobs.
  • -Literal avoids wildcard expansion for paths containing *, ?, or [ ].
  • PSIsContainer ensures you only enter directories.

Guardrails and edge cases

  • Prefer -LiteralPath when handling user input to avoid unintended wildcard matches.
  • Cross-provider safety: Push-Location works for other providers too (e.g., Registry). Resolve-Path guarantees a valid provider-qualified path.
  • Fail fast: Use -ErrorAction Stop on IO commands to make try/catch meaningful.
  • Don’t Pop-Location in catch only: always place it in finally so it runs on success and failure.

Production-Grade Practices: Logging, CI/CD, Containers

Structured, quiet-by-default logging

Prefer Write-Information over Write-Host in library code. It’s pipeline-friendly and respects $InformationPreference. Consider attaching metadata for easy parsing:

$meta = @{ event = 'cd'; action = 'enter'; path = $resolved; ts = (Get-Date).ToString('o') }
Write-Information ($meta | ConvertTo-Json -Compress)

For long-running scripts, add timings:

$sw = [System.Diagnostics.Stopwatch]::StartNew()
Use-Location -Path $Path -Script {
  # work...
}
$sw.Stop()
Write-Information ("work.ms={0}" -f $sw.ElapsedMilliseconds)

Safer file IO with absolute paths

Whenever possible, compute absolute paths once and pass them through, instead of relying on the working directory. You can still use scoped location changes for tools that insist on relative paths.

$root = (Resolve-Path -LiteralPath $Path -ErrorAction Stop).Path
$report = Join-Path $root 'report.json'

Use-Location -Path $root -Script {
  # For tools that require cwd
  & git status --porcelain
}

# For everything else, be explicit
Set-Content -LiteralPath $report -Value '{"ok":true}' -NoNewline

CI/CD: GitHub Actions, Azure DevOps, and beyond

CI agents often set a default working directory, but steps can still leave it in a different place. Always scope directory changes inside each step.

# GitHub Actions example
$workspace = $Env:GITHUB_WORKSPACE
Use-Location -Path $workspace -Script {
  npm ci
  npm run build
  # Artifacts are produced in a known location
}

In Azure DevOps, ensure your PowerShell task either sets workingDirectory or uses the scoped pattern above. This keeps parallel jobs independent and reproducible.

Containers and scheduled tasks

  • Containers: Don’t assume /app or C:\app. Always resolve and scope to mounted volumes (e.g., $Env:WORKDIR or a known mount path).
  • Windows Scheduled Tasks and Services: The default working directory may be C:\Windows\System32. Use the scoped pattern to preempt surprises, even if you set Start in/WorkingDirectory.
# Windows Task example
$in   = 'C:\data\incoming'
$out  = 'C:\data\processed'
Use-Location -Path $in -Script {
  Get-ChildItem -File | ForEach-Object {
    $dest = Join-Path $out $_.Name
    Copy-Item -LiteralPath $_.FullName -Destination $dest -Force
  }
}

Error handling that actually handles errors

Make non-terminating errors terminate where it matters, so your catch blocks run and cleanup happens:

$ErrorActionPreference = 'Stop'
Use-Location -Path './data' -Script {
  Get-ChildItem -File -ErrorAction Stop | Remove-Item -ErrorAction Stop -WhatIf
}

A complete, robust example

This function combines path resolution, validation, structured logging, and guaranteed cleanup. It’s suitable for automation, CI, or local scripts.

function Invoke-ScopedFileWork {
  [CmdletBinding(SupportsShouldProcess=$true)]
  param(
    [Parameter(Mandatory)][string]$Path = './data',
    [Parameter()][ValidateSet('Info','Verbose','Silent')][string]$Log = 'Info'
  )

  $ErrorActionPreference = 'Stop'

  $resolved = Resolve-Path -LiteralPath $Path
  $item = Get-Item -LiteralPath $resolved
  if (-not $item.PSIsContainer) { throw "'$Path' is not a directory" }

  $logEnter = "Enter -> $resolved"; $logBack = $null
  switch ($Log) {
    'Info'    { Write-Information $logEnter }
    'Verbose' { Write-Verbose $logEnter }
    default   { }
  }

  if ($PSCmdlet.ShouldProcess($resolved, 'Push-Location')) {
    Push-Location -Path $resolved
    try {
      # Example work: list top 3 largest files
      Get-ChildItem -File | Sort-Object Length -Descending | Select-Object -First 3 Name, Length
    } catch {
      Write-Warning ('Failed in {0}: {1}' -f (Get-Location), $_.Exception.Message)
      throw
    } finally {
      Pop-Location
      $logBack = ('Back -> {0}' -f (Get-Location))
      switch ($Log) {
        'Info'    { Write-Information $logBack }
        'Verbose' { Write-Verbose $logBack }
        default   { }
      }
    }
  }
}

# Usage
Invoke-ScopedFileWork -Path './data' -Log Info -Verbose

Quick checklist

  • Always resolve first: Resolve-Path -ErrorAction Stop.
  • Use Push-Location/Pop-Location with try/finally.
  • Log before and after to make changes visible.
  • Prefer absolute paths for file writes (Join-Path + resolved root).
  • Use -LiteralPath for user input or paths with wildcards.
  • Enable -WhatIf and -Confirm for potentially destructive workflows.
  • In CI/containers/services, never assume the working directory—set or scope it.

Follow this pattern and you’ll get reliable context, safer changes, and predictable cleanup—no more scripts stuck in the wrong folder.

← All Posts Home →