Netscoot.Shared/Common/Paths.ps1

function Resolve-SymlinkPath {
    # Resolve symlinked ancestors of an absolute path, segment by segment, over the portion that
    # exists; any not-yet-existing tail (e.g. a move destination) is appended unchanged. This makes
    # our paths match the canonical form git and the dotnet CLI use - on macOS the temp/repository root
    # /var/folders/... is a symlink to /private/var/folders/..., and without this our /var-form
    # paths diverge from dotnet sln / git bookkeeping, breaking reconciliation on a repository under a
    # symlinked directory.
    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$Full)
    $sep = [System.IO.Path]::DirectorySeparatorChar
    $cur = "$sep"
    foreach ($part in ($Full.Split($sep))) {
        if ([string]::IsNullOrEmpty($part)) { continue }
        $cand = [System.IO.Path]::Combine($cur, $part)
        if (Test-Path -LiteralPath $cand) {
            $item = Get-Item -LiteralPath $cand -Force
            $link = $null
            try { $link = $item.ResolveLinkTarget($true) } catch { $link = $null }
            $cur = if ($link) { $link.FullName } else { $item.FullName }
        } else {
            $cur = $cand   # nothing below here exists yet; keep as typed
        }
    }
    return $cur
}

function Resolve-FullPath {
    # Absolute, normalized path. Does not require the path to exist and emits no errors. On Unix it
    # also resolves symlinked ancestors so the result is canonical (matching git/dotnet); Windows
    # GetFullPath is sufficient (no /var-style ancestor symlinks).
    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$Path)
    $full = if ([System.IO.Path]::IsPathRooted($Path)) {
        [System.IO.Path]::GetFullPath($Path)
    } else {
        [System.IO.Path]::GetFullPath((Join-Path (Get-Location).Path $Path))
    }
    if (Test-IsWindowsHost) { return $full }
    return (Resolve-SymlinkPath -Full $full)
}

function Test-PathEqual {
    # OS-aware path equality (see Platform.ps1 for $script:PathComparison).
    [CmdletBinding()]
    param([Parameter(Mandatory)][AllowEmptyString()][string]$A,
          [Parameter(Mandatory)][AllowEmptyString()][string]$B)
    return [string]::Equals($A.TrimEnd('\', '/'), $B.TrimEnd('\', '/'), $script:PathComparison)
}

function Test-PathInList {
    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$Path,
          [string[]]$List)
    foreach ($item in $List) { if (Test-PathEqual $Path $item) { return $true } }
    return $false
}

function Get-RelativePathSafe {
    # Relative path from directory $From to file/dir $To, returned with the platform separator
    # (MSBuild accepts both). On PowerShell 7 we use [IO.Path]::GetRelativePath, which is correct
    # on Windows and Unix. Windows PowerShell 5.1 (.NET Framework 4.x) lacks GetRelativePath, so
    # there we fall back to Uri.MakeRelativeUri - which only works for Windows drive-letter paths,
    # but 5.1 is Windows-only so that is fine.
    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$From,
          [Parameter(Mandatory)][string]$To)
    $fromFull = (Resolve-FullPath $From).TrimEnd('\', '/')
    $toFull = Resolve-FullPath $To
    if ($PSVersionTable.PSEdition -eq 'Core') {
        return [System.IO.Path]::GetRelativePath($fromFull, $toFull)
    }
    $fromUri = [Uri]($fromFull + [System.IO.Path]::DirectorySeparatorChar)
    $toUri = [Uri]$toFull
    $rel = [Uri]::UnescapeDataString($fromUri.MakeRelativeUri($toUri).ToString())
    return ($rel.Replace('/', '\'))
}

function Test-PathUnderAny {
    # True if $Path is strictly inside any directory in $Dirs. OS-aware.
    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$Path,
          [AllowEmptyCollection()][string[]]$Dirs = @())
    foreach ($d in $Dirs) { if (Test-PathUnder -Path $Path -Dir $d) { return $true } }
    return $false
}

function Get-PathSuffixScore {
    # Count of matching trailing path segments between two paths (OS-aware, separator-agnostic).
    # E.g. 'src/Widgets/Widgets.csproj' vs 'tools/Widgets/Widgets.csproj' -> 2 (Widgets, Widgets.csproj).
    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$A,
          [Parameter(Mandatory)][string]$B)
    $sa = ($A.Replace('/', '\')).TrimEnd('\').Split('\')
    $sb = ($B.Replace('/', '\')).TrimEnd('\').Split('\')
    $i = $sa.Length - 1
    $j = $sb.Length - 1
    $n = 0
    while ($i -ge 0 -and $j -ge 0 -and [string]::Equals($sa[$i], $sb[$j], $script:PathComparison)) {
        $n++; $i--; $j--
    }
    return $n
}

function Select-BestSuffixMatch {
    # Given the original (now-broken) path and a set of candidate paths that share its leaf name,
    # return the single candidate sharing the most trailing path segments - but only when that
    # maximum is unique. Returns $null on a tie, which the caller treats as genuinely ambiguous.
    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$Original,
          [Parameter(Mandatory)][string[]]$Candidates)
    $scored = foreach ($c in $Candidates) {
        [pscustomobject]@{ Path = $c; Score = (Get-PathSuffixScore -A $Original -B $c) }
    }
    $max = ($scored | Measure-Object -Property Score -Maximum).Maximum
    $top = @($scored | Where-Object { $_.Score -eq $max })
    if ($top.Count -eq 1) { return $top[0].Path }
    return $null
}

function Test-PathOverlap {
    # True if two directory paths overlap: identical, or one nested inside the other. Used to
    # refuse a move whose destination sits inside the source (or vice versa) - that move cannot
    # complete and would otherwise leave a half-reconciled repository behind.
    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$A,
          [Parameter(Mandatory)][string]$B)
    return (Test-PathEqual $A $B) -or (Test-PathUnder -Path $A -Dir $B) -or (Test-PathUnder -Path $B -Dir $A)
}

function Test-PathUnder {
    # True if $Path is strictly inside directory $Dir (not equal to it). OS-aware compare.
    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$Path,
          [Parameter(Mandatory)][string]$Dir)
    $p = (Resolve-FullPath $Path).TrimEnd('\', '/')
    $d = (Resolve-FullPath $Dir).TrimEnd('\', '/')
    if (Test-PathEqual $p $d) { return $false }
    # Normalize separators so the prefix test is separator-agnostic.
    $pn = ($p.Replace('/', '\')) + '\'
    $dn = ($d.Replace('/', '\')) + '\'
    return $pn.StartsWith($dn, $script:PathComparison)
}

function Resolve-MoveTarget {
    # Resolve a move's final target path the way `git mv` does, so every mover behaves the same:
    # - Destination is an existing directory -> move INTO it, keeping the source's leaf name
    # (git mv src/Tarragon libs -> libs/Tarragon).
    # - otherwise -> Destination IS the new path (a rename: git mv src/Tarragon libs/Tarragon).
    # Returns the absolute final path. Does not check for conflicts - the caller errors if the
    # returned path already exists (mirroring git mv, which refuses without -f).
    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$Source,
          [Parameter(Mandatory)][string]$Destination)
    # Normalize away a trailing slash: GetFullPath keeps it, and it would otherwise leak into the
    # rename target (and make `git mv src dest/` error where `git mv src dest` renames). A trailing
    # slash is treated as a no-op here, so './libs' and './libs/' behave identically.
    $dest = [System.IO.Path]::GetFullPath($Destination)
    $trimmed = $dest.TrimEnd([char]'\', [char]'/')
    if ($trimmed) { $dest = $trimmed }
    if (Test-Path -LiteralPath $dest -PathType Container) {
        return (Join-Path $dest (Split-Path -Leaf $Source))
    }
    return $dest
}