windowspowershellpathrelative-pathworking-directory

How does PowerShell treat "." in paths?


Consider the following sequence of commands upon opening a PowerShell terminal:

PS C:\Users\username> cd source
PS C:\Users\username\source> $dir = ".\temp"
PS C:\Users\username\source> [System.IO.Path]::GetFullPath($dir)
C:\Users\username\temp

Now this:

PS C:\Users\username> cd source
PS C:\Users\username\source> powershell
Windows PowerShell
Copyright (C) Microsoft Corporation. All rights reserved.

Try the new cross-platform PowerShell https://aka.ms/pscore6

PS C:\Users\username\source> $dir = ".\temp"
PS C:\Users\username\source> [System.IO.Path]::GetFullPath($dir)
C:\Users\username\source\temp

Why does PowerShell interpret "." relative to the directory in which PowerShell was started, rather than the current directory? How can I get it to interpret "." relative to the current directory?


Solution

  • As js2010's helpful answer states, it is the use of a .NET method that introduces the problem:
    .NET's single, process-wide current directory typically and by design[1] differs from PowerShell's runspace-specific one.

    This has the following implications:

    The next section shows how to safely pass relative PowerShell paths to .NET methods, whereas the bottom section solves the specific problem in your question: how to resolve an arbitrary, given PowerShell path to an absolute, native filesystem path.

    Caveat:


    Passing a known relative file-system path safely to a .NET method:

    As stated, the discrepancy in current directories requires that an absolute path be passed to .NET methods, arrived at based on PowerShell's current directory.

    The examples assume relative path someFile.txt to be passed to .NET method [IO.File]::ReadAllText() and [IO.File]::WriteAllText()

    Note that simple string interpolation is used, with / (which can be used interchangeably with \) used to join the path components; if the current directory happens to be the root directory, you'll end up with 2 path separators, but that doesn't affect functionality. If you still need to avoid that, however, use the Join-Path cmdlet instead.

    If the path is known to exist:

    Use Convert-Path:

    [IO.File]::ReadAllText((Convert-Path -LiteralPath someFile.txt))
    

    That Convert-Path and Resolve-Path only work with existing paths (as of PowerShell (Core) 7.2) is unfortunate; providing an opt- in for nonexistent path has been proposed in GitHub issue #2993.

    Similarly, it would be helpful if Convert-Path and Resolve-Path supported a -PSProvider parameter to allow specifying the target provider explicitly, as Get-Location already supports - see GitHub issue #10494.

    If the path is one to be created:

    The simplest and robust approach is to use New-Item to let PowerShell create the item beforehand, which returns a file-system information object whose .FullName property reflects the full, native path:

    # For a *directory* path, use -Type Directory
    [IO.File]::WriteAllText(
      (New-Item -Type File ./someFile.txt).FullName,
      "sample content"
    )
    

    If you don't want to create the file/directory in PowerShell beforehand, there are several approaches:

    [IO.File]::WriteAllText("$PWD/someFile.txt", "sample content")
    
    [IO.File]::WriteAllText("$($PWD.ProviderPath)/someFile.txt", "sample content")
    
    [IO.File]::WriteAllText(
      "$((Get-Location -PSProvider FileSystem).ProviderPath)/someFile.txt"),
      "sample content"
    )
    

    Resolving any given file-system path to a full, native one:

    That is, you may have to resolve to a full, file-system native a path that is given to you, which may or may not be relative, and may or may not be based on a PowerShell-specific drive (which .NET knows nothing about).

    If the path exists, use Convert-Path to resolve any PowerShell filesystem path to an absolute, filesystem-native one:

    $dir = "./temp"
    Convert-Path -LiteralPath $dir
    

    The related Resolve-Path cmdlet provides similar functionality, but it doesn't resolve paths based on PowerShell-specific drives (created with New-PsDrive) to their underlying native filesystem paths.

    If the path doesn't exist (yet):

    Note:

    In PowerShell (Core) v6+, which builds on .NET Core / .NET 5+, you can use the new [IO.Path]::GetFullPath() overload that accepts a reference directory for the specified relative path:

    $dir = "./temp"
    [IO.Path]::GetFullPath($dir, $PWD.ProviderPath)
    

    In Windows PowerShell, you additionally need [IO.Path]::Combine():

    Note: In the simplest case, i.e. if your relative paths never start with \ (or /)[3] and you don't care about normalizing the resulting path (by resolving .\ or ..\ components and/or having / normalized to \), [IO.Path]::Combine() alone is enough:

    # !! Note the limitations.
    $dir = "temp"
    [IO.Path]::Combine($PWD.ProviderPath, $dir)
    

    Combining [IO.Path]::Combine() with [IO.Path]::GetFullPath() overcomes these limitations:

    # Robust solution
    $dir = "./temp"
    [IO.Path]::GetFullPath([IO.Path]::Combine($PWD.ProviderPath, $dir))
    

    [1] While a given process typically has only one PowerShell runspace (session), the potential for multiple ones to coexist in a process means that it is conceptually impossible for all of them to sync their individual working directories with the one and only process-wide .NET working directory. For a more in-depth explanation, see this GitHub issue.

    [2] That is, paths based on PowerShell-specific drives (see New-PSDrive) must be translated to paths based on drives known to all processes.

    [3] As Theo notes, [IO.Path]::Combine() considers a (non-UNC) path that starts with \ (or /) a full one, despite being rooted only relative to the current drive. Therefore, such paths must be prefixed with the drive spec. of the native file-system directory underlying PowerShell's current location to form a truly full path.