NetscootShared/Common/Git.ps1

function Get-RepositoryRoot {
    # Walk up from $StartPath looking for a .git dir/file; fall back to the start path.
    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$StartPath)
    $dir = Get-Item -LiteralPath $StartPath
    if (-not $dir.PSIsContainer) { $dir = $dir.Directory }
    while ($null -ne $dir) {
        if (Test-Path (Join-Path $dir.FullName '.git')) { return $dir.FullName }
        $dir = $dir.Parent
    }
    return (Get-Item -LiteralPath $StartPath).FullName
}

function Get-NestedWorktreePath {
    # Absolute paths of git worktrees that live strictly inside $Root - linked worktrees (e.g.
    # under .claude/worktrees/<id>/) hold duplicate copies of the repository's solutions/projects and
    # would poison a recursive scan (double-counted membership, etc.). Callers exclude these.
    # Empty when git is unavailable, $Root is not in a repository, or nothing nests under it.
    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$Root)
    if (-not (Test-GitAvailable)) { return @() }
    $rootFull = Resolve-FullPath $Root
    if (-not (Test-Path -LiteralPath $rootFull)) { return @() }
    $lines = $null
    Push-Location $rootFull
    try { $lines = & git worktree list --porcelain 2>$null; $ok = ($LASTEXITCODE -eq 0) }
    catch { $ok = $false }
    finally { Pop-Location }
    if (-not $ok) { return @() }
    $nested = @()
    foreach ($l in $lines) {
        if ($l -match '^worktree\s+(.+)$') {
            $wt = Resolve-FullPath ($Matches[1].Trim())
            if (Test-PathUnder -Path $wt -Dir $rootFull) { $nested += $wt }   # strictly under root only
        }
    }
    return $nested
}

function Invoke-Git {
    # Mutating git call: runs, then throws on non-zero exit. Windows PowerShell 5.1 turns native
    # stderr (even a warning such as a line-ending notice) into a terminating error when
    # $ErrorActionPreference is Stop; force Continue around the call so it does not.
    [CmdletBinding()]
    param(
        [string]$RepositoryRoot,
        [Parameter(Mandatory)][string[]]$Arguments
    )
    $gitArgs = if ($RepositoryRoot) { @('-C', $RepositoryRoot) + $Arguments } else { $Arguments }
    Write-Verbose "git $($gitArgs -join ' ')"
    $prev = $ErrorActionPreference
    $ErrorActionPreference = 'Continue'
    try { & git @gitArgs 2>&1 | Write-Verbose }
    finally { $ErrorActionPreference = $prev }
    if ($LASTEXITCODE -ne 0) {
        throw "git $($gitArgs -join ' ') failed with exit code $LASTEXITCODE"
    }
}

function Move-PathTracked {
    # Move one path: git mv when tracked (preserves history), else Move-Item. Creates the
    # destination parent if needed. Shared by every move cmdlet's filesystem step.
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][bool]$UseGit,
        [Parameter(Mandatory)][string]$Source,
        [Parameter(Mandatory)][string]$Destination,
        [Parameter(Mandatory)][string]$RepositoryRoot
    )
    $parent = Split-Path -Parent $Destination
    if ($parent -and -not (Test-Path -LiteralPath $parent)) {
        New-Item -ItemType Directory -Path $parent -Force | Out-Null
    }
    if ($UseGit -and (Test-GitTracked -Path $Source)) {
        Invoke-Git -RepositoryRoot $RepositoryRoot -Arguments @('mv', '--', $Source, $Destination)
    } else {
        # No -Force on purpose: a plain Move-Item refuses an existing destination instead of
        # clobbering it. With Resolve-MoveTarget already rejecting an existing target, this keeps the
        # non-git path non-destructive even if a tampered journal sets Force. Do NOT add -Force here.
        Move-Item -LiteralPath $Source -Destination $Destination
    }
}

function Test-GitTracked {
    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$Path)
    $dir = Split-Path -Parent $Path
    try {
        Push-Location $dir
        & git ls-files --error-unmatch -- $Path *> $null
        return ($LASTEXITCODE -eq 0)
    } catch { return $false }
    finally { Pop-Location }
}