diff --git a/modules/Shmuelie.Git/CHANGELOG.md b/modules/Shmuelie.Git/CHANGELOG.md index 394396e..1bb08b0 100644 --- a/modules/Shmuelie.Git/CHANGELOG.md +++ b/modules/Shmuelie.Git/CHANGELOG.md @@ -7,6 +7,9 @@ Versions change only when a release is cut; unreleased work stays under ## [Unreleased] ### Added +- Added consistent `-Path` repository targeting to Git status, worktree, sync, + stale-branch, and worktree-update helpers so callers can operate on another + repository without changing the current location. - `New-Worktree` and `Add-Worktree` now accept `-WorktreePath` to place the created worktree at an explicit destination while preserving the conventional sibling path when omitted. diff --git a/modules/Shmuelie.Git/Public/Find-StaleBranch.ps1 b/modules/Shmuelie.Git/Public/Find-StaleBranch.ps1 index bf5c849..7c42ae0 100644 --- a/modules/Shmuelie.Git/Public/Find-StaleBranch.ps1 +++ b/modules/Shmuelie.Git/Public/Find-StaleBranch.ps1 @@ -12,6 +12,8 @@ function Find-StaleBranch { was deleted (completed/merged, abandoned, or manually deleted). .PARAMETER Remote The remote to check against. Defaults to 'origin'. + .PARAMETER Path + Directory inside the git working tree to inspect. Defaults to the current location. .PARAMETER User Filter to branches matching user//*. Defaults to the current git user name derived from user.email config. @@ -44,6 +46,10 @@ function Find-StaleBranch { param( [string]$Remote = 'origin', + [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)] + [Alias('RepositoryPath', 'RepoPath')] + [string]$Path, + [string]$User, [switch]$IncludePrStatus, @@ -53,6 +59,9 @@ function Find-StaleBranch { [switch]$All ) process { + $repoPath = Resolve-GitRepositoryPath -Path $Path + if (-not $repoPath) { return } + # Issue #4 / CVE-2024-1874: these arguments flow through az.cmd, so # neutralize cmd.exe metacharacter injection before invoking az. $SafeBranchNamePattern = '^[A-Za-z0-9._/-]+$' @@ -70,7 +79,7 @@ function Find-StaleBranch { # Determine user filter if (-not $All -and -not $User) { - $email = git config user.email 2>$null + $email = git -C $repoPath config user.email 2>$null if ($email -match '^([^@]+)@') { $User = $Matches[1] } @@ -78,9 +87,9 @@ function Find-StaleBranch { $userPrefix = if (-not $All -and $User) { "user/$User/" } else { $null } # Get all local branches - $localBranches = git --no-pager for-each-ref --format='%(refname:short)|%(upstream:short)|%(upstream:track)' refs/heads/ 2>$null + $localBranches = git -C $repoPath --no-pager for-each-ref --format='%(refname:short)|%(upstream:short)|%(upstream:track)' refs/heads/ 2>$null if ($LASTEXITCODE -ne 0) { - throw "Not a git repository (or git failed) in '$((Get-Location).Path)'." + throw "Not a git repository (or git failed) in '$repoPath'." } $candidates = @() foreach ($line in $localBranches) { @@ -104,7 +113,7 @@ function Find-StaleBranch { # Batch-fetch all remote refs matching user prefix in one call $remoteRefSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $lsRemoteFilter = if ($userPrefix) { "refs/heads/$userPrefix*" } else { 'refs/heads/*' } - $remoteRefs = git --no-pager ls-remote --heads $Remote $lsRemoteFilter 2>$null + $remoteRefs = git -C $repoPath --no-pager ls-remote --heads $Remote $lsRemoteFilter 2>$null foreach ($refLine in $remoteRefs) { if ($refLine -match '\trefs/heads/(.+)$') { $remoteRefSet.Add($Matches[1]) | Out-Null @@ -114,7 +123,7 @@ function Find-StaleBranch { # Build worktree lookup for path info $worktreePaths = @{} try { - $worktrees = Get-Worktrees + $worktrees = Get-Worktrees -Path $repoPath foreach ($wt in $worktrees) { $worktreePaths[$wt.Branch] = $wt.Path } @@ -123,7 +132,7 @@ function Find-StaleBranch { # ADO context for PR lookups $adoContext = $null if ($IncludePrStatus) { - $gitUrl = git config --get "remote.$Remote.url" 2>$null + $gitUrl = git -C $repoPath config --get "remote.$Remote.url" 2>$null if ($gitUrl -match 'dev\.azure\.com/(?[^/]+)/(?[^/]+)/_git/(?.+)$' -or $gitUrl -match '(?[^/]+)\.visualstudio\.com/(?:DefaultCollection/)?(?[^/]+)/_git/(?.+)$') { $org = & $decodeRemoteUrlComponent $Matches['org'] diff --git a/modules/Shmuelie.Git/Public/Get-GitStatusSummary.ps1 b/modules/Shmuelie.Git/Public/Get-GitStatusSummary.ps1 index 1eb901e..74db670 100644 --- a/modules/Shmuelie.Git/Public/Get-GitStatusSummary.ps1 +++ b/modules/Shmuelie.Git/Public/Get-GitStatusSummary.ps1 @@ -33,18 +33,20 @@ function Get-GitStatusSummary { [OutputType('GitStatusSummary')] [CmdletBinding()] param( - [Parameter(Position = 0)] + [Parameter(Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)] + [Alias('RepositoryPath', 'RepoPath')] [string]$Path ) - $pushed = $false - if ($Path) { - Push-Location $Path -ErrorAction Stop - $pushed = $true - } - try { + process { + $targetPath = if ($Path) { + (Resolve-Path -LiteralPath $Path -ErrorAction Stop | Select-Object -First 1).ProviderPath + } else { + (Get-Location).ProviderPath + } + # Quick check: are we in a git repo? - $null = git rev-parse --is-inside-work-tree 2>$null + $null = git -C $targetPath rev-parse --is-inside-work-tree 2>$null if ($LASTEXITCODE -ne 0) { return [PSCustomObject]@{ PSTypeName = 'GitStatusSummary' @@ -73,7 +75,7 @@ function Get-GitStatusSummary { } # Parse git status - $lines = git status --porcelain=v1 --branch 2>$null + $lines = git -C $targetPath status --porcelain=v1 --branch 2>$null $branch = $null; $upstream = $null; $ahead = 0; $behind = 0; $upstreamGone = $false $idxA = 0; $idxM = 0; $idxD = 0 $wrkA = 0; $wrkM = 0; $wrkD = 0 @@ -129,10 +131,10 @@ function Get-GitStatusSummary { # Worktree path. Git prints '/' separators even on Windows; normalize only # there so Unix paths are not corrupted by replacing path separators. - $toplevel = ConvertTo-NativeGitPath (git rev-parse --show-toplevel 2>$null) + $toplevel = ConvertTo-NativeGitPath (git -C $targetPath rev-parse --show-toplevel 2>$null) # Detect in-progress git operations via .git/ sentinel files - $gitDir = ConvertTo-NativeGitPath (git rev-parse --git-dir 2>$null) + $gitDir = ConvertTo-NativeGitPath (git -C $targetPath rev-parse --path-format=absolute --git-dir 2>$null) $operation = if ($gitDir) { $rebaseMergePath = Join-Path $gitDir 'rebase-merge' $rebaseApplyPath = Join-Path $gitDir 'rebase-apply' @@ -164,7 +166,7 @@ function Get-GitStatusSummary { } # Repo name from remote URL or directory name - $remoteUrl = git remote get-url origin 2>$null + $remoteUrl = git -C $targetPath remote get-url origin 2>$null $repoName = if ($remoteUrl) { ($remoteUrl.Substring($remoteUrl.LastIndexOf('/') + 1)) -replace '\.git$', '' } elseif ($toplevel) { @@ -175,11 +177,11 @@ function Get-GitStatusSummary { # Stash count $stashCount = 0 - $stashOutput = git rev-list --walk-reflogs --count refs/stash 2>$null + $stashOutput = git -C $targetPath rev-list --walk-reflogs --count refs/stash 2>$null if ($LASTEXITCODE -eq 0 -and $stashOutput) { $stashCount = [int]$stashOutput } # Relative path from worktree root - $currentPath = (Get-Location).Path + $currentPath = $targetPath $relativePath = '' if ($toplevel -and $currentPath.StartsWith($toplevel, [System.StringComparison]::OrdinalIgnoreCase)) { $relativePath = $currentPath.Substring($toplevel.Length) @@ -253,7 +255,5 @@ function Get-GitStatusSummary { HasChanges = ($hasIndex -or $hasWorking -or $hasUntracked -or $hasConflicts) StatusString = $sb.ToString() } - } finally { - if ($pushed) { Pop-Location } } } diff --git a/modules/Shmuelie.Git/Public/Sync-GitRemote.ps1 b/modules/Shmuelie.Git/Public/Sync-GitRemote.ps1 index 723a0fb..390cdda 100644 --- a/modules/Shmuelie.Git/Public/Sync-GitRemote.ps1 +++ b/modules/Shmuelie.Git/Public/Sync-GitRemote.ps1 @@ -32,6 +32,8 @@ function Sync-GitRemote { groups by remote. .PARAMETER Remote Fetch from a specific remote instead of all remotes. + .PARAMETER Path + Directory inside the git working tree to fetch. Defaults to the current location. .PARAMETER NoPrune Skip removing remote-tracking references that no longer exist on the remote. By default, deleted remote branches are pruned. @@ -67,6 +69,10 @@ function Sync-GitRemote { [ValidateNotNullOrEmpty()] [string]$Remote, + [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)] + [Alias('RepositoryPath', 'RepoPath')] + [string]$Path, + [switch]$NoPrune, [hashtable]$GitHubAccountMap, @@ -76,6 +82,10 @@ function Sync-GitRemote { [switch]$NoGitHubAccountResolve ) + process { + $repoPath = Resolve-GitRepositoryPath -Path $Path + if (-not $repoPath) { return } + if ($null -eq $script:GitHubAccountCache) { $script:GitHubAccountCache = [System.Collections.Concurrent.ConcurrentDictionary[string, string]]::new([System.StringComparer]::OrdinalIgnoreCase) } @@ -99,11 +109,11 @@ function Sync-GitRemote { @($accounts | Group-Object Host | Where-Object { $_.Count -gt 1 }).Count -gt 0 ) if ($canResolve) { - $remoteSet = if ($Remote) { @($Remote) } else { @(git remote 2>$null) } + $remoteSet = if ($Remote) { @($Remote) } else { @(git -C $repoPath remote 2>$null) } foreach ($remoteName in $remoteSet) { if (-not $remoteName) { continue } - $url = git remote get-url $remoteName 2>$null + $url = git -C $repoPath remote get-url $remoteName 2>$null if (-not $url) { continue } $info = Get-GitHubRemoteInfo -Url "$url" if (-not $info) { continue } @@ -137,7 +147,7 @@ function Sync-GitRemote { $output = $null $failed = $false - $gitDirectoryArgs = @('-C', (Get-Location).ProviderPath) + $gitDirectoryArgs = @('-C', $repoPath) if ($resolvePlan.Count -eq 0) { # Original behavior: one 'git fetch' for the whole request. @@ -208,10 +218,10 @@ function Sync-GitRemote { if ($deletedRef -match '^([^/]+)/(.+)$') { $remoteName = $Matches[1] $branchName = $Matches[2] - $existingRemote = git config --get "branch.$branchName.remote" 2>$null - if (-not $existingRemote -and (git rev-parse --verify "refs/heads/$branchName" 2>$null)) { - git config "branch.$branchName.remote" $remoteName - git config "branch.$branchName.merge" "refs/heads/$branchName" + $existingRemote = git -C $repoPath config --get "branch.$branchName.remote" 2>$null + if (-not $existingRemote -and (git -C $repoPath rev-parse --verify "refs/heads/$branchName" 2>$null)) { + git -C $repoPath config "branch.$branchName.remote" $remoteName + git -C $repoPath config "branch.$branchName.merge" "refs/heads/$branchName" Write-Verbose "Set tracking config on '$branchName' -> '$remoteName/$branchName' (pruned) for [gone] detection" } } @@ -234,6 +244,7 @@ function Sync-GitRemote { } # Skip "Fetching " and "From " header lines } + } } function Get-GitFetchParseEnvironment { diff --git a/modules/Shmuelie.Git/Public/Update-Worktrees.ps1 b/modules/Shmuelie.Git/Public/Update-Worktrees.ps1 index 19883f0..02b2d2c 100644 --- a/modules/Shmuelie.Git/Public/Update-Worktrees.ps1 +++ b/modules/Shmuelie.Git/Public/Update-Worktrees.ps1 @@ -10,6 +10,8 @@ function Update-Worktrees { Uses a bulk 'git for-each-ref' call to get ahead/behind counts for all branches in one pass, then checks only worktrees that need merging for local changes or in-progress git operations. + .PARAMETER Path + Directory inside the git working tree to update. Defaults to the current location. .PARAMETER CheckRemote Also query the remote for branches with no local upstream, reclassifying NoUpstream worktrees so deleted/stale remote branches are detected. @@ -39,6 +41,10 @@ function Update-Worktrees { [OutputType('WorktreeUpdateResult')] [CmdletBinding(SupportsShouldProcess)] param( + [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)] + [Alias('RepositoryPath', 'RepoPath')] + [string]$Path, + [switch]$CheckRemote, [hashtable]$GitHubAccountMap, @@ -48,6 +54,9 @@ function Update-Worktrees { [switch]$NoGitHubAccountResolve ) process { + $repoPath = Resolve-GitRepositoryPath -Path $Path + if (-not $repoPath) { return } + $previousView = $PSStyle.Progress.View try { @@ -61,6 +70,7 @@ function Update-Worktrees { if ($PSBoundParameters.ContainsKey('GitHubAccountMap')) { $syncParams.GitHubAccountMap = $GitHubAccountMap } if ($PSBoundParameters.ContainsKey('GitHubAccountResolver')) { $syncParams.GitHubAccountResolver = $GitHubAccountResolver } if ($NoGitHubAccountResolve) { $syncParams.NoGitHubAccountResolve = $true } + $syncParams.Path = $repoPath $fetchResults = Sync-GitRemote @syncParams if (-not $?) { return } @@ -73,7 +83,7 @@ function Update-Worktrees { } Write-Progress -Activity 'Updating Worktrees' -Status 'Getting Worktrees' -PercentComplete 0 -Id 0 - $worktrees = Get-Worktrees + $worktrees = Get-Worktrees -Path $repoPath if ($null -eq $worktrees -or @($worktrees).Count -eq 0) { Write-Progress -Activity 'Updating Worktrees' -Id 0 -Completed return @@ -81,7 +91,7 @@ function Update-Worktrees { # Bulk-fetch ahead/behind counts for all branches in one git call $branchStatus = @{} - $refLines = git for-each-ref --format='%(refname:short)|%(upstream:short)|%(upstream:track)' refs/heads/ 2>&1 + $refLines = git -C $repoPath for-each-ref --format='%(refname:short)|%(upstream:short)|%(upstream:track)' refs/heads/ 2>&1 foreach ($line in $refLines) { $parts = $line -split '\|', 3 if ($parts.Count -lt 3) { continue } @@ -175,7 +185,7 @@ function Update-Worktrees { if ($noUpstream.Count -gt 0) { Write-Progress -Activity 'Updating Worktrees' -Status 'Checking remote refs' -PercentComplete 40 -Id 0 $remoteRefSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) - $remoteRefs = git --no-pager ls-remote --heads origin 2>$null + $remoteRefs = git -C $repoPath --no-pager ls-remote --heads origin 2>$null foreach ($refLine in $remoteRefs) { if ($refLine -match '\trefs/heads/(.+)$') { $remoteRefSet.Add($Matches[1]) | Out-Null @@ -217,17 +227,12 @@ function Update-Worktrees { continue } - Push-Location $wt.Path - try { - $dirtyOutput = git status --porcelain 2>&1 - $isDirty = $dirtyOutput -and @($dirtyOutput).Count -gt 0 - if ($isDirty) { - $dirtyWorktrees.Add($wt) - } else { - $cleanWorktrees.Add($wt) - } - } finally { - Pop-Location + $dirtyOutput = git -C $wt.Path status --porcelain 2>&1 + $isDirty = $dirtyOutput -and @($dirtyOutput).Count -gt 0 + if ($isDirty) { + $dirtyWorktrees.Add($wt) + } else { + $cleanWorktrees.Add($wt) } } @@ -235,23 +240,18 @@ function Update-Worktrees { Write-Progress -Activity 'Updating Worktrees' -Status "Merging $($cleanWorktrees.Count) clean worktrees" -PercentComplete 60 -Id 0 $cleanResults = $cleanWorktrees | ForEach-Object -Parallel { $wt = $_ - Push-Location $wt.Path - try { - git merge --ff-only '@{upstream}' --quiet 2>&1 | Out-Null - $mergeSuccess = $LASTEXITCODE -eq 0 - - [PSCustomObject]@{ - PSTypeName = 'WorktreeUpdateResult' - Branch = $wt.Branch - Path = $wt.Path - Status = if ($mergeSuccess) { 'Updated' } else { 'Failed' } - BehindBy = $wt.Behind - Stashed = $false - Operation = $null - PopFailed = $false - } - } finally { - Pop-Location + git -C $wt.Path merge --ff-only '@{upstream}' --quiet 2>&1 | Out-Null + $mergeSuccess = $LASTEXITCODE -eq 0 + + [PSCustomObject]@{ + PSTypeName = 'WorktreeUpdateResult' + Branch = $wt.Branch + Path = $wt.Path + Status = if ($mergeSuccess) { 'Updated' } else { 'Failed' } + BehindBy = $wt.Behind + Stashed = $false + Operation = $null + PopFailed = $false } } -ThrottleLimit 4 @@ -263,56 +263,51 @@ function Update-Worktrees { } foreach ($wt in $dirtyWorktrees) { - Push-Location $wt.Path - try { - $stashed = $false - $dirtyOutput = git status --porcelain 2>&1 - $isDirty = $dirtyOutput -and @($dirtyOutput).Count -gt 0 - - if ($isDirty) { - git stash push --include-untracked --quiet 2>&1 | Out-Null - $stashed = $LASTEXITCODE -eq 0 - } - - # Only fast-forward when the tree is safe: either it was clean, or we - # successfully stashed it. A dirty tree that failed to stash must NOT be - # fast-forwarded, and we must NOT run `git stash pop` (which would pop an - # unrelated, pre-existing stash into this worktree). - if ($isDirty -and -not $stashed) { - $mergeResults.Add([PSCustomObject]@{ - PSTypeName = 'WorktreeUpdateResult' - Branch = $wt.Branch - Path = $wt.Path - Status = 'StashFailed' - BehindBy = $wt.Behind - Stashed = $false - Operation = $null - PopFailed = $false - }) - continue - } - - git merge --ff-only '@{upstream}' --quiet 2>&1 | Out-Null - $mergeSuccess = $LASTEXITCODE -eq 0 + $stashed = $false + $dirtyOutput = git -C $wt.Path status --porcelain 2>&1 + $isDirty = $dirtyOutput -and @($dirtyOutput).Count -gt 0 - if ($stashed) { - git stash pop --quiet 2>&1 | Out-Null - $popFailed = $LASTEXITCODE -ne 0 - } + if ($isDirty) { + git -C $wt.Path stash push --include-untracked --quiet 2>&1 | Out-Null + $stashed = $LASTEXITCODE -eq 0 + } + # Only fast-forward when the tree is safe: either it was clean, or we + # successfully stashed it. A dirty tree that failed to stash must NOT be + # fast-forwarded, and we must NOT run `git stash pop` (which would pop an + # unrelated, pre-existing stash into this worktree). + if ($isDirty -and -not $stashed) { $mergeResults.Add([PSCustomObject]@{ PSTypeName = 'WorktreeUpdateResult' Branch = $wt.Branch Path = $wt.Path - Status = if ($mergeSuccess) { 'Updated' } else { 'Failed' } + Status = 'StashFailed' BehindBy = $wt.Behind - Stashed = $stashed + Stashed = $false Operation = $null - PopFailed = if ($stashed) { $popFailed } else { $false } + PopFailed = $false }) - } finally { - Pop-Location + continue + } + + git -C $wt.Path merge --ff-only '@{upstream}' --quiet 2>&1 | Out-Null + $mergeSuccess = $LASTEXITCODE -eq 0 + + if ($stashed) { + git -C $wt.Path stash pop --quiet 2>&1 | Out-Null + $popFailed = $LASTEXITCODE -ne 0 } + + $mergeResults.Add([PSCustomObject]@{ + PSTypeName = 'WorktreeUpdateResult' + Branch = $wt.Branch + Path = $wt.Path + Status = if ($mergeSuccess) { 'Updated' } else { 'Failed' } + BehindBy = $wt.Behind + Stashed = $stashed + Operation = $null + PopFailed = if ($stashed) { $popFailed } else { $false } + }) } foreach ($mr in $mergeResults) { diff --git a/modules/Shmuelie.Git/Public/Worktrees.ps1 b/modules/Shmuelie.Git/Public/Worktrees.ps1 index 2eb891b..bff4a8d 100644 --- a/modules/Shmuelie.Git/Public/Worktrees.ps1 +++ b/modules/Shmuelie.Git/Public/Worktrees.ps1 @@ -1,5 +1,44 @@ using module ../Classes/WorktreeSetValuesGenerator.psm1 +function Resolve-GitRepositoryPath { + <# + .SYNOPSIS + Resolve and validate a path inside a git working tree. + #> + [CmdletBinding()] + param( + [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)] + [Alias('RepositoryPath', 'RepoPath')] + [string]$Path, + + [switch]$AllowBare + ) + + $candidate = if ($Path) { $Path } else { (Get-Location).ProviderPath } + try { + $resolved = Resolve-Path -LiteralPath $candidate -ErrorAction Stop | Select-Object -First 1 + $providerPath = $resolved.ProviderPath + } catch { + Write-Error "Git repository path not found: '$candidate'." + return + } + + $inside = git -C $providerPath rev-parse --is-inside-work-tree 2>$null + if ($LASTEXITCODE -ne 0 -or "$inside".Trim() -ne 'true') { + if ($AllowBare) { + $bare = git -C $providerPath rev-parse --is-bare-repository 2>$null + if ($LASTEXITCODE -eq 0 -and "$bare".Trim() -eq 'true') { + return $providerPath + } + } + + Write-Error "Path '$providerPath' is not inside a git working tree." + return + } + + $providerPath +} + function Get-Worktrees { <# .SYNOPSIS @@ -10,68 +49,82 @@ function Get-Worktrees { state: Bare, Detached, Locked/LockReason, and Prunable/PrunableReason. The boolean state fields are always present (defaulting to $false) and the reason fields default to an empty string. + .PARAMETER Path + Directory inside the git working tree to inspect. Defaults to the current location. .EXAMPLE Get-Worktrees Returns all worktrees for the current repository. .EXAMPLE + Get-Worktrees -Path C:\repos\project + Returns all worktrees for the repository containing the specified path. + .EXAMPLE Get-Worktrees | Where-Object Prunable Returns worktrees whose working directory is gone and can be pruned. #> [OutputType('Worktree')] [CmdletBinding()] - param() - - $lines = git worktree list --porcelain - $newEntry = { - @{ - PSTypeName = 'Worktree' - Bare = $false - Detached = $false - Locked = $false - LockReason = '' - Prunable = $false - PrunableReason = '' - } - } - $entry = & $newEntry - foreach ($line in $lines) { - if ([string]::IsNullOrWhiteSpace($line)) { - if ($entry.ContainsKey('Path')) { - [PSCustomObject]$entry - $entry = & $newEntry + param( + [Parameter(Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)] + [Alias('RepositoryPath', 'RepoPath')] + [string]$Path + ) + + process { + $repoPath = Resolve-GitRepositoryPath -Path $Path -AllowBare + if (-not $repoPath) { return } + + $lines = git -C $repoPath worktree list --porcelain + $newEntry = { + @{ + PSTypeName = 'Worktree' + Bare = $false + Detached = $false + Locked = $false + LockReason = '' + Prunable = $false + PrunableReason = '' } - continue } - if ($line.StartsWith('worktree ')) { - $rawPath = $line -replace '^worktree ' - # git lists worktrees whose directory was deleted manually; keep the - # git-reported path when it no longer resolves rather than storing $null. - $resolved = Resolve-Path -LiteralPath $rawPath -ErrorAction SilentlyContinue - $entry['Path'] = if ($resolved) { $resolved.Path } else { $rawPath } - } elseif ($line.StartsWith('HEAD ')) { - $entry['Commit'] = $line -replace '^HEAD ' - } elseif ($line.StartsWith('branch refs/heads/')) { - $entry['Branch'] = $line -replace '^branch refs/heads/' - } elseif ($line -eq 'detached') { - $entry['Branch'] = '(detached)' - $entry['Detached'] = $true - } elseif ($line -eq 'bare') { - $entry['Bare'] = $true - } elseif ($line -eq 'locked' -or $line.StartsWith('locked ')) { - $entry['Locked'] = $true - if ($line.Length -gt 'locked '.Length) { - $entry['LockReason'] = $line.Substring('locked '.Length).Trim() + $entry = & $newEntry + foreach ($line in $lines) { + if ([string]::IsNullOrWhiteSpace($line)) { + if ($entry.ContainsKey('Path')) { + [PSCustomObject]$entry + $entry = & $newEntry + } + continue } - } elseif ($line -eq 'prunable' -or $line.StartsWith('prunable ')) { - $entry['Prunable'] = $true - if ($line.Length -gt 'prunable '.Length) { - $entry['PrunableReason'] = $line.Substring('prunable '.Length).Trim() + if ($line.StartsWith('worktree ')) { + $rawPath = $line -replace '^worktree ' + # git lists worktrees whose directory was deleted manually; keep the + # git-reported path when it no longer resolves rather than storing $null. + $resolved = Resolve-Path -LiteralPath $rawPath -ErrorAction SilentlyContinue + $entry['Path'] = if ($resolved) { $resolved.Path } else { $rawPath } + } elseif ($line.StartsWith('HEAD ')) { + $entry['Commit'] = $line -replace '^HEAD ' + } elseif ($line.StartsWith('branch refs/heads/')) { + $entry['Branch'] = $line -replace '^branch refs/heads/' + } elseif ($line -eq 'detached') { + $entry['Branch'] = '(detached)' + $entry['Detached'] = $true + } elseif ($line -eq 'bare') { + $entry['Bare'] = $true + } elseif ($line -eq 'locked' -or $line.StartsWith('locked ')) { + $entry['Locked'] = $true + if ($line.Length -gt 'locked '.Length) { + $entry['LockReason'] = $line.Substring('locked '.Length).Trim() + } + } elseif ($line -eq 'prunable' -or $line.StartsWith('prunable ')) { + $entry['Prunable'] = $true + if ($line.Length -gt 'prunable '.Length) { + $entry['PrunableReason'] = $line.Substring('prunable '.Length).Trim() + } } } - } - # Emit the last entry - if ($entry.ContainsKey('Path')) { - [PSCustomObject]$entry + # Emit the last entry + if ($entry.ContainsKey('Path')) { + [PSCustomObject]$entry + } } } @@ -110,16 +163,29 @@ function Get-CurrentWorktree { Get the worktree that contains the current directory. .DESCRIPTION Returns the worktree whose path is equal to or a parent of the current working directory. + .PARAMETER Path + Directory inside the git working tree to inspect. Defaults to the current location. .EXAMPLE Get-CurrentWorktree Returns the worktree object for the current location. + .EXAMPLE + Get-CurrentWorktree -Path C:\repos\project\src + Returns the worktree object containing the specified path. #> [CmdletBinding()] - param() + param( + [Parameter(Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)] + [Alias('RepositoryPath', 'RepoPath')] + [string]$Path + ) + + process { + $currentPath = Resolve-GitRepositoryPath -Path $Path + if (-not $currentPath) { return } - $currentPath = (Get-Location).Path - Get-Worktrees | Where-Object { - Test-PathContains -ReferencePath $_.Path -CandidatePath $currentPath + Get-Worktrees -Path $currentPath | Where-Object { + Test-PathContains -ReferencePath $_.Path -CandidatePath $currentPath + } } } @@ -129,15 +195,26 @@ function Get-RepositoryName { Get the name of the current git repository. .DESCRIPTION Extracts the repository name from the origin remote URL, stripping any trailing .git suffix. + .PARAMETER Path + Directory inside the git working tree to inspect. Defaults to the current location. .EXAMPLE Get-RepositoryName Returns the repository name, e.g. 'MyRepo'. #> [CmdletBinding()] - param() + param( + [Parameter(Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)] + [Alias('RepositoryPath', 'RepoPath')] + [string]$Path + ) - git remote get-url origin | ForEach-Object { - $_.SubString($_.LastIndexOf('/') + 1) -replace '\.git$','' + process { + $repoPath = Resolve-GitRepositoryPath -Path $Path + if (-not $repoPath) { return } + + git -C $repoPath remote get-url origin | ForEach-Object { + $_.SubString($_.LastIndexOf('/') + 1) -replace '\.git$','' + } } } @@ -148,23 +225,37 @@ function Get-RootWorktree { .DESCRIPTION Resolves Git's common directory from any repository subdirectory, then matches its parent directory against the worktree list. + .PARAMETER Path + Directory inside the git working tree to inspect. Defaults to the current location. .EXAMPLE Get-RootWorktree Returns the worktree object for the root of the repository. + .EXAMPLE + Get-RootWorktree -Path C:\repos\project\src + Returns the root worktree for the repository containing the specified path. #> [CmdletBinding()] - param() + param( + [Parameter(Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)] + [Alias('RepositoryPath', 'RepoPath')] + [string]$Path + ) - $commonDir = git rev-parse --path-format=absolute --git-common-dir 2>$null - if ($LASTEXITCODE -ne 0 -or -not $commonDir) { - Write-Error 'Not in a git repository.' - return - } + process { + $repoPath = Resolve-GitRepositoryPath -Path $Path + if (-not $repoPath) { return } - $rootPath = [IO.Path]::GetFullPath((Split-Path $commonDir -Parent)) + $commonDir = git -C $repoPath rev-parse --path-format=absolute --git-common-dir 2>$null + if ($LASTEXITCODE -ne 0 -or -not $commonDir) { + Write-Error "Path '$repoPath' is not inside a git working tree." + return + } + + $rootPath = [IO.Path]::GetFullPath((Split-Path $commonDir -Parent)) - Get-Worktrees | Where-Object { - [IO.Path]::GetFullPath("$($_.Path)").Equals($rootPath, [System.StringComparison]::OrdinalIgnoreCase) + Get-Worktrees -Path $repoPath | Where-Object { + [IO.Path]::GetFullPath("$($_.Path)").Equals($rootPath, [System.StringComparison]::OrdinalIgnoreCase) + } } } @@ -176,30 +267,44 @@ function Get-WorktreePath { Constructs the worktree path from the repository container and branch name. .PARAMETER BranchName The branch name to resolve to a worktree path. + .PARAMETER Path + Directory inside the git working tree to inspect. Defaults to the current location. .EXAMPLE Get-WorktreePath -BranchName feature/my-feature Returns the expected worktree path for the given branch. + .EXAMPLE + Get-WorktreePath -BranchName feature/my-feature -Path C:\repos\project + Returns the expected worktree path for the repository containing the specified path. #> [CmdletBinding()] param( - [Parameter(Mandatory)] - [string]$BranchName + [Parameter(Mandatory, Position = 0)] + [string]$BranchName, + + [Parameter(Position = 1, ValueFromPipelineByPropertyName)] + [Alias('RepositoryPath', 'RepoPath')] + [string]$Path ) - $root = Get-RootWorktree | Select-Object -First 1 - if (-not $root) { return } + process { + $repoPath = Resolve-GitRepositoryPath -Path $Path + if (-not $repoPath) { return } + + $root = Get-RootWorktree -Path $repoPath | Select-Object -First 1 + if (-not $root) { return } + + $separator = [IO.Path]::DirectorySeparatorChar + $rootPath = [IO.Path]::GetFullPath("$($root.Path)").TrimEnd($separator) + $branchPath = ($root.Branch -replace '[/\\]', $separator).Trim($separator) + $branchSuffix = "$separator$branchPath" + $container = if ($rootPath.EndsWith($branchSuffix, [System.StringComparison]::OrdinalIgnoreCase)) { + $rootPath.Substring(0, $rootPath.Length - $branchSuffix.Length) + } else { + Split-Path $rootPath -Parent + } - $separator = [IO.Path]::DirectorySeparatorChar - $rootPath = [IO.Path]::GetFullPath("$($root.Path)").TrimEnd($separator) - $branchPath = ($root.Branch -replace '[/\\]', $separator).Trim($separator) - $branchSuffix = "$separator$branchPath" - $container = if ($rootPath.EndsWith($branchSuffix, [System.StringComparison]::OrdinalIgnoreCase)) { - $rootPath.Substring(0, $rootPath.Length - $branchSuffix.Length) - } else { - Split-Path $rootPath -Parent + Join-Path $container $BranchName } - - Join-Path $container $BranchName } function Invoke-GitWorktreeAdd { @@ -213,10 +318,13 @@ function Invoke-GitWorktreeAdd { [string[]]$Arguments, [Parameter(Mandatory)] - [string]$FailureContext + [string]$FailureContext, + + [string]$RepositoryPath ) - $output = & git @Arguments 2>&1 + $gitArguments = if ($RepositoryPath) { @('-C', $RepositoryPath) + $Arguments } else { $Arguments } + $output = & git @gitArguments 2>&1 $exitCode = $LASTEXITCODE if ($exitCode -eq 0) { return $true @@ -239,15 +347,18 @@ function Resolve-CreatedWorktreePath { [CmdletBinding()] param( [Parameter(Mandatory)] - [string]$Path + [string]$Path, + + [string]$BasePath ) - $resolved = Resolve-Path -LiteralPath $Path -ErrorAction SilentlyContinue | Select-Object -First 1 + $candidate = if ($BasePath -and -not [IO.Path]::IsPathRooted($Path)) { Join-Path $BasePath $Path } else { $Path } + $resolved = Resolve-Path -LiteralPath $candidate -ErrorAction SilentlyContinue | Select-Object -First 1 if ($resolved) { return $resolved.ProviderPath } - [IO.Path]::GetFullPath($Path) + [IO.Path]::GetFullPath($candidate) } function Add-Worktree { @@ -255,18 +366,20 @@ function Add-Worktree { .SYNOPSIS Checkout an existing branch to a worktree .PARAMETER BranchName - Name of the branch + Name of the branch. + .PARAMETER Path + Directory inside the git working tree to add the worktree from. Defaults to the current location. .PARAMETER WorktreePath Optional destination path for the new worktree. When omitted, the path is derived from the repository container and branch name. .PARAMETER SetLocation - Whether to change the current directory to the new worktree + Whether to change the current directory to the new worktree. .EXAMPLE Add-Worktree -BranchName feature/my-feature -SetLocation Checks out the existing branch to a new worktree and navigates to it. .EXAMPLE - Add-Worktree -BranchName feature/my-feature -WorktreePath ../custom-feature - Checks out the existing branch to the supplied worktree path. + Add-Worktree -Path C:\repos\project -BranchName feature/my-feature -WorktreePath ../custom-feature + Checks out the existing branch from the specified repository to the supplied worktree path. #> [CmdletBinding(SupportsShouldProcess)] param( @@ -274,25 +387,33 @@ function Add-Worktree { [ValidateNotNullOrEmpty()] [string]$BranchName, + [Parameter(ValueFromPipelineByPropertyName)] + [Alias('RepositoryPath', 'RepoPath')] + [string]$Path, + [ValidateNotNullOrEmpty()] [string]$WorktreePath, [switch]$SetLocation = $false ) process { + $repoPath = Resolve-GitRepositoryPath -Path $Path + if (-not $repoPath) { return } + $resolvedWorktreePath = if ($PSBoundParameters.ContainsKey('WorktreePath')) { $WorktreePath } else { - Get-WorktreePath -BranchName $BranchName + Get-WorktreePath -BranchName $BranchName -Path $repoPath } if (-not $resolvedWorktreePath) { return } if ($PSCmdlet.ShouldProcess($resolvedWorktreePath, "Add worktree for branch '$BranchName'")) { $created = Invoke-GitWorktreeAdd ` + -RepositoryPath $repoPath ` -Arguments @('worktree', 'add', $resolvedWorktreePath, $BranchName) ` -FailureContext "branch '$BranchName' at '$resolvedWorktreePath'" if ($created -and $SetLocation) { - Set-Location -LiteralPath (Resolve-CreatedWorktreePath -Path $resolvedWorktreePath) + Set-Location -LiteralPath (Resolve-CreatedWorktreePath -Path $resolvedWorktreePath -BasePath $repoPath) } } } @@ -300,11 +421,18 @@ function Add-Worktree { function Get-GitBranchUser { [CmdletBinding()] - param() + param( + [Parameter(ValueFromPipelineByPropertyName)] + [Alias('RepositoryPath', 'RepoPath')] + [string]$Path + ) + + $repoPath = Resolve-GitRepositoryPath -Path $Path + if (-not $repoPath) { return } $candidate = $env:GITHUB_USER if (-not $candidate) { - $email = git config --get user.email 2>$null + $email = git -C $repoPath config --get user.email 2>$null if ($email -match '^([^@]+)@') { $candidate = $Matches[1] } @@ -337,6 +465,8 @@ function New-Worktree { .PARAMETER NoPrefix Use WorkName as the branch name verbatim, without the kind prefix (e.g. checking out an existing branch like 'main' or 'master'). + .PARAMETER Path + Directory inside the git working tree to create the worktree from. Defaults to the current location. .PARAMETER WorktreePath Optional destination path for the new worktree. When omitted, the path is derived from the repository container and branch name. @@ -355,8 +485,8 @@ function New-Worktree { New-Worktree -WorkName main -NoPrefix -SetLocation Creates a worktree for a branch named exactly 'main' with no kind prefix. .EXAMPLE - New-Worktree -WorkName my-feature -WorktreePath ../custom-feature - Creates branch user//my-feature in the supplied worktree path. + New-Worktree -Path C:\repos\project -WorkName my-feature -WorktreePath ../custom-feature + Creates branch user//my-feature from the specified repository in the supplied worktree path. #> [CmdletBinding(SupportsShouldProcess)] param( @@ -372,18 +502,25 @@ function New-Worktree { [switch]$NoPrefix, + [Parameter(ValueFromPipelineByPropertyName)] + [Alias('RepositoryPath', 'RepoPath')] + [string]$Path, + [ValidateNotNullOrEmpty()] [string]$WorktreePath, [switch]$SetLocation = $false ) process { + $repoPath = Resolve-GitRepositoryPath -Path $Path + if (-not $repoPath) { return } + $branchName = if ($NoPrefix) { $WorkName } else { switch ($Kind) { 'user' { - $branchUser = if ($UserName) { $UserName } else { Get-GitBranchUser } + $branchUser = if ($UserName) { $UserName } else { Get-GitBranchUser -Path $repoPath } "user/$branchUser/$WorkName" } 'feature' { "feature/$WorkName" } @@ -393,16 +530,17 @@ function New-Worktree { $resolvedWorktreePath = if ($PSBoundParameters.ContainsKey('WorktreePath')) { $WorktreePath } else { - Get-WorktreePath -BranchName $branchName + Get-WorktreePath -BranchName $branchName -Path $repoPath } if (-not $resolvedWorktreePath) { return } if ($PSCmdlet.ShouldProcess($resolvedWorktreePath, "Create worktree for new branch '$branchName'")) { $created = Invoke-GitWorktreeAdd ` + -RepositoryPath $repoPath ` -Arguments @('worktree', 'add', '-b', $branchName, $resolvedWorktreePath) ` -FailureContext "new branch '$branchName' at '$resolvedWorktreePath'" if ($created -and $SetLocation) { - Set-Location -LiteralPath (Resolve-CreatedWorktreePath -Path $resolvedWorktreePath) + Set-Location -LiteralPath (Resolve-CreatedWorktreePath -Path $resolvedWorktreePath -BasePath $repoPath) } } } diff --git a/modules/Shmuelie.Git/README.md b/modules/Shmuelie.Git/README.md index 43777dd..d791f03 100644 --- a/modules/Shmuelie.Git/README.md +++ b/modules/Shmuelie.Git/README.md @@ -17,20 +17,20 @@ Import-Module Shmuelie.Git |---|---| | `New-Repository` | Clone a URL into a standard `///` layout (parses GitHub and Azure DevOps URLs) | | `Repair-RepositoryLayout` | Conform existing clones and worktrees to that layout | -| `Sync-GitRemote` | Fetch all remotes with pruning, returning typed results; picks the right `gh` account per host (github.com/GHE) when several are signed in | -| `Get-Worktrees` | List worktrees for the current repository | -| `Get-CurrentWorktree` / `Get-RootWorktree` | Resolve the worktree for the current directory or the repository root | -| `Get-WorktreePath` | Compute the path a branch's worktree would use | -| `New-Worktree` | Create a branch and check it out to a worktree, optionally at `-WorktreePath` | -| `Add-Worktree` | Check out an existing branch to a worktree, optionally at `-WorktreePath` | +| `Sync-GitRemote` | Fetch all remotes for the current or `-Path` repository with pruning, returning typed results; picks the right `gh` account per host (github.com/GHE) when several are signed in | +| `Get-Worktrees` | List worktrees for the current or `-Path` repository | +| `Get-CurrentWorktree` / `Get-RootWorktree` | Resolve the worktree for the current directory/`-Path` or the repository root | +| `Get-WorktreePath` | Compute the path a branch's worktree would use for the current or `-Path` repository | +| `New-Worktree` | Create a branch from the current or `-Path` repository and check it out to a worktree, optionally at destination `-WorktreePath` | +| `Add-Worktree` | Check out an existing branch from the current or `-Path` repository to a worktree, optionally at destination `-WorktreePath` | | `Remove-Worktree` | Remove a worktree by branch name or path (optionally deleting its branch) | | `Set-Worktree` | Switch to a worktree by branch name or path | | `Remove-StaleWorktree` | Prune stale worktree administrative entries for deleted worktree directories | | `Repair-Worktree` | Repair worktree links after a repository or worktree move | | `Lock-Worktree` / `Unlock-Worktree` | Lock or unlock a worktree by branch name | -| `Update-Worktrees` | Fast-forward every worktree from upstream (forwards the `Sync-GitRemote` GitHub-account options to the fetch) | -| `Find-StaleBranch` | Find local branches whose upstream branch is gone (`-IncludeNeverPushed` also includes local-only branches) | -| `Get-GitStatusSummary` | Parse `git status` into a typed object (branch, ahead/behind, conflicts, stash, operation) | +| `Update-Worktrees` | Fast-forward every worktree for the current or `-Path` repository from upstream (forwards the `Sync-GitRemote` GitHub-account options to the fetch) | +| `Find-StaleBranch` | Find local branches in the current or `-Path` repository whose upstream branch is gone (`-IncludeNeverPushed` also includes local-only branches) | +| `Get-GitStatusSummary` | Parse `git status` for the current or `-Path` repository into a typed object (branch, ahead/behind, conflicts, stash, operation) | | `Format-GitStatusSegment` | Render a `GitStatusSummary` as a colored posh-git-style prompt segment (`$PSStyle` string; `-ShowChangeCounts` toggles the change counts) | | `Update-WorktreePrediction` | Refresh the bundled predictor for the current directory | diff --git a/tests/Shmuelie.Git.Tests.ps1 b/tests/Shmuelie.Git.Tests.ps1 index 00ca01b..ae515db 100644 --- a/tests/Shmuelie.Git.Tests.ps1 +++ b/tests/Shmuelie.Git.Tests.ps1 @@ -168,6 +168,114 @@ Describe 'Add-Worktree creation' -Skip:(-not (Get-Command git -ErrorAction Silen } } + +Describe 'Git repository -Path parameters' { + BeforeEach { + $script:pathCallerRepo = New-TestRepo -Path (Join-Path $TestDrive ([guid]::NewGuid().ToString('N'))) + $script:pathTargetRepo = New-TestRepo -Path (Join-Path $TestDrive ([guid]::NewGuid().ToString('N'))) + } + + It 'preserves default current-directory behavior for read-only helpers' { + Push-Location $script:pathCallerRepo + try { + (Get-Worktrees | Select-Object -First 1).Path | Should -BeExactly $script:pathCallerRepo + (Get-CurrentWorktree).Path | Should -BeExactly $script:pathCallerRepo + (Get-RootWorktree).Path | Should -BeExactly $script:pathCallerRepo + (Get-GitStatusSummary).WorktreePath | Should -BeExactly $script:pathCallerRepo + Get-WorktreePath -BranchName sibling | Should -BeExactly (Join-Path (Split-Path $script:pathCallerRepo -Parent) 'sibling') + } finally { + Pop-Location + } + } + + It 'targets an explicit repository path without changing the caller location' { + $targetChild = Join-Path $script:pathTargetRepo 'src' + New-Item -ItemType Directory -Path $targetChild -Force | Out-Null + Invoke-Git @('-C', $script:pathTargetRepo, 'branch', 'user/test/local-only') + $callerLocation = $null + + Push-Location $script:pathCallerRepo + try { + $callerLocation = (Get-Location).Path + (Get-Worktrees -Path $script:pathTargetRepo | Select-Object -First 1).Path | Should -BeExactly $script:pathTargetRepo + (Get-CurrentWorktree -Path $targetChild).Path | Should -BeExactly $script:pathTargetRepo + (Get-RootWorktree -Path $targetChild).Path | Should -BeExactly $script:pathTargetRepo + (Get-GitStatusSummary -Path $targetChild).WorktreePath | Should -BeExactly $script:pathTargetRepo + (Find-StaleBranch -Path $script:pathTargetRepo -User test -IncludeNeverPushed).Branch | Should -Be 'user/test/local-only' + Get-WorktreePath -BranchName sibling -Path $script:pathTargetRepo | Should -BeExactly (Join-Path (Split-Path $script:pathTargetRepo -Parent) 'sibling') + (Get-Location).Path | Should -BeExactly $callerLocation + } finally { + Pop-Location + } + } + + It 'accepts repository paths from pipeline input' { + $status = [PSCustomObject]@{ Path = $script:pathTargetRepo } | Get-GitStatusSummary + $worktree = [PSCustomObject]@{ Path = $script:pathTargetRepo } | Get-Worktrees | Select-Object -First 1 + + $status.WorktreePath | Should -BeExactly $script:pathTargetRepo + $worktree.Path | Should -BeExactly $script:pathTargetRepo + } + + It 'reports a clear error for a non-git path' { + $notRepo = Join-Path $TestDrive 'not-a-git-worktree' + New-Item -ItemType Directory -Path $notRepo -Force | Out-Null + + Get-Worktrees -Path $notRepo -ErrorAction SilentlyContinue -ErrorVariable errors | Should -BeNullOrEmpty + + $errors | Should -HaveCount 1 + $errors[0].Exception.Message | Should -Match 'not inside a git working tree' + } + + It 'uses explicit source -Path with destination -WorktreePath for worktree creation without changing caller location' { + Invoke-Git @('-C', $script:pathTargetRepo, 'branch', 'existing-work') + $existingPath = Join-Path $TestDrive 'explicit-existing-worktree' + $newPath = Join-Path $TestDrive 'explicit-new-worktree' + $callerLocation = $null + + Push-Location $script:pathCallerRepo + try { + $callerLocation = (Get-Location).Path + Add-Worktree -Path $script:pathTargetRepo -BranchName existing-work -WorktreePath $existingPath + Test-Path -LiteralPath $existingPath -PathType Container | Should -BeTrue + (Get-Worktrees -Path $script:pathTargetRepo).Path | Should -Contain (Resolve-Path -LiteralPath $existingPath).Path + + New-Worktree -Path $script:pathTargetRepo -WorkName explicit-new -NoPrefix -WorktreePath $newPath + Test-Path -LiteralPath $newPath -PathType Container | Should -BeTrue + (Get-Worktrees -Path $script:pathTargetRepo).Path | Should -Contain (Resolve-Path -LiteralPath $newPath).Path + + (Get-Location).Path | Should -BeExactly $callerLocation + } finally { + Pop-Location + } + } + + It 'passes explicit -Path through Sync-GitRemote and Update-Worktrees without changing caller location' { + Invoke-Git @('-C', $script:pathTargetRepo, 'remote', 'add', 'origin', 'https://github.com/contoso/repo.git') + Mock -ModuleName Shmuelie.Git Get-GitHubSignedInAccount { @() } + Mock -ModuleName Shmuelie.Git Invoke-GitWithEnvironment { + $Arguments[0] | Should -Be '-C' + $Arguments[1] | Should -BeExactly $script:pathTargetRepo + [PSCustomObject]@{ + PSTypeName = 'GitInvocationResult' + ExitCode = 0 + Output = @(' * [new branch] main -> origin/main') + } + } + + $callerLocation = $null + Push-Location $script:pathCallerRepo + try { + $callerLocation = (Get-Location).Path + (Sync-GitRemote -Path $script:pathTargetRepo -Remote origin -NoGitHubAccountResolve).Ref | Should -Be 'origin/main' + (Update-Worktrees -Path $script:pathTargetRepo -NoGitHubAccountResolve | Where-Object Branch -eq main).Status | Should -Be 'NoUpstream' + (Get-Location).Path | Should -BeExactly $callerLocation + } finally { + Pop-Location + } + } +} + Describe 'Get-GitStatusSummary' { It 'does not pop the caller location stack when -Path cannot be pushed' { $startingPath = (Get-Location).Path