From bf6f954c85d06c7a55df2ba522045f54e07605f3 Mon Sep 17 00:00:00 2001 From: Shmueli Englard Date: Fri, 21 Aug 2026 15:07:12 -0700 Subject: [PATCH] Add Path targeting to Git helpers Thread a consistent -Path repository context through status, worktree, sync, stale branch, and update helpers so callers can operate on another repository without changing their location. Route git calls through git -C for explicit paths and validate non-git paths with clear errors while preserving existing current-directory behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b98f01a0-a039-492f-8576-b1917e54742d --- modules/Shmuelie.Git/CHANGELOG.md | 5 + .../Shmuelie.Git/Public/Find-StaleBranch.ps1 | 21 +- .../Public/Get-GitStatusSummary.ps1 | 32 +- .../Shmuelie.Git/Public/Sync-GitRemote.ps1 | 25 +- .../Shmuelie.Git/Public/Update-Worktrees.ps1 | 133 ++++--- modules/Shmuelie.Git/Public/Worktrees.ps1 | 338 +++++++++++++----- modules/Shmuelie.Git/README.md | 18 +- tests/Shmuelie.Git.Tests.ps1 | 131 +++++++ 8 files changed, 499 insertions(+), 204 deletions(-) diff --git a/modules/Shmuelie.Git/CHANGELOG.md b/modules/Shmuelie.Git/CHANGELOG.md index cfee53b..aa7c0ef 100644 --- a/modules/Shmuelie.Git/CHANGELOG.md +++ b/modules/Shmuelie.Git/CHANGELOG.md @@ -6,6 +6,11 @@ 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. + ## [0.5.0] - 2026-08-20 ### Added 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 41553ca..c129970 100644 --- a/modules/Shmuelie.Git/Public/Update-Worktrees.ps1 +++ b/modules/Shmuelie.Git/Public/Update-Worktrees.ps1 @@ -13,6 +13,8 @@ function Update-Worktrees { .PARAMETER CheckRemote Also query the remote for branches with no local upstream, reclassifying NoUpstream worktrees so deleted/stale remote branches are detected. + .PARAMETER Path + Directory inside the git working tree to update. Defaults to the current location. .PARAMETER GitHubAccountMap Forwarded to Sync-GitRemote. Maps a repository ("host/owner" or bare "owner") to the `gh` account that should fetch it when several accounts are @@ -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 } @@ -171,7 +181,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 @@ -197,17 +207,12 @@ function Update-Worktrees { $cleanWorktrees = [System.Collections.Generic.List[PSObject]]::new() $dirtyWorktrees = [System.Collections.Generic.List[PSObject]]::new() foreach ($wt in $behindWorktrees) { - 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) } } @@ -217,22 +222,17 @@ 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 - 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 + PopFailed = $false } } -ThrottleLimit 4 @@ -244,54 +244,49 @@ 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 - } + $stashed = $false + $dirtyOutput = git -C $wt.Path status --porcelain 2>&1 + $isDirty = $dirtyOutput -and @($dirtyOutput).Count -gt 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 - PopFailed = $false - }) - continue - } - - git merge --ff-only '@{upstream}' --quiet 2>&1 | Out-Null - $mergeSuccess = $LASTEXITCODE -eq 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 - PopFailed = if ($stashed) { $popFailed } else { $false } + Stashed = $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 + 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 ff7f097..8161a51 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 + ) - $currentPath = (Get-Location).Path - Get-Worktrees | Where-Object { - Test-PathContains -ReferencePath $_.Path -CandidatePath $currentPath + process { + $currentPath = Resolve-GitRepositoryPath -Path $Path + if (-not $currentPath) { return } + + 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 + ) + + process { + $repoPath = Resolve-GitRepositoryPath -Path $Path + if (-not $repoPath) { return } - git remote get-url origin | ForEach-Object { - $_.SubString($_.LastIndexOf('/') + 1) -replace '\.git$','' + 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 Add-Worktree { @@ -208,6 +313,8 @@ function Add-Worktree { Checkout an existing branch to a worktree .PARAMETER BranchName Name of the branch + .PARAMETER Path + Directory inside the git working tree to add the worktree from. Defaults to the current location. .PARAMETER SetLocation Whether to change the current directory to the new worktree .EXAMPLE @@ -219,12 +326,18 @@ function Add-Worktree { [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$BranchName, + [Parameter(ValueFromPipelineByPropertyName)] + [Alias('RepositoryPath', 'RepoPath')] + [string]$Path, [switch]$SetLocation = $false ) process { - $worktreePath = Get-WorktreePath -BranchName $BranchName + $repoPath = Resolve-GitRepositoryPath -Path $Path + if (-not $repoPath) { return } + + $worktreePath = Get-WorktreePath -BranchName $BranchName -Path $repoPath if ($PSCmdlet.ShouldProcess($worktreePath, "Add worktree for branch '$BranchName'")) { - git worktree add $worktreePath $BranchName + git -C $repoPath worktree add $worktreePath $BranchName if (($LASTEXITCODE -eq 0) -and $SetLocation) { Set-Location -Path $worktreePath } @@ -234,11 +347,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] } @@ -271,6 +391,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 SetLocation Whether to change the current directory to the new worktree. .EXAMPLE @@ -300,24 +422,31 @@ function New-Worktree { [switch]$NoPrefix, + [Parameter(ValueFromPipelineByPropertyName)] + [Alias('RepositoryPath', 'RepoPath')] + [string]$Path, + [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" } 'release' { "release/$WorkName" } } } - $worktreePath = Get-WorktreePath -BranchName $branchName + $worktreePath = Get-WorktreePath -BranchName $branchName -Path $repoPath if ($PSCmdlet.ShouldProcess($worktreePath, "Create worktree for new branch '$branchName'")) { - git worktree add -b $branchName $worktreePath + git -C $repoPath worktree add -b $branchName $worktreePath if (($LASTEXITCODE -eq 0) -and $SetLocation) { Set-Location -Path $worktreePath } @@ -373,10 +502,13 @@ function Resolve-WorktreeTarget { [string]$BranchName, [Parameter(Mandatory, ParameterSetName = 'Path')] - [string]$Path + [string]$Path, + + [string]$RepositoryPath ) - $worktrees = @(Get-Worktrees) + $contextPath = if ($RepositoryPath) { $RepositoryPath } elseif ($PSCmdlet.ParameterSetName -eq 'Path' -and (Test-Path -LiteralPath $Path -PathType Container)) { $Path } else { $null } + $worktrees = @(Get-Worktrees -Path $contextPath) if ($PSCmdlet.ParameterSetName -eq 'Path') { $matches = @($worktrees | Where-Object { Test-WorktreePathEquals -Left $_.Path -Right $Path }) if ($matches.Count -eq 0) { @@ -416,8 +548,9 @@ function Remove-Worktree { non-standard worktree locations are supported. Detached worktrees must be addressed by `-Path` because their branch label is ambiguous. .PARAMETER Path - The actual filesystem path of the worktree to remove. Accepts pipeline input - by property name from `Get-Worktrees` and related objects. + With -BranchName, directory inside the git working tree used to resolve the branch. + Without -BranchName, the actual filesystem path of the worktree to remove. + Accepts pipeline input by property name from `Get-Worktrees` and related objects. .PARAMETER RemoveBranch Also remove the branch when the target worktree is backed by a branch. .PARAMETER Force @@ -432,11 +565,11 @@ function Remove-Worktree { [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'Path')] param( [Parameter(Mandatory, Position = 0, ParameterSetName = 'BranchName', ValueFromPipelineByPropertyName)] - [ValidateSet([WorktreeSetValuesGenerator])] [Alias('Branch')] [string]$BranchName, [Parameter(Mandatory, ParameterSetName = 'Path', ValueFromPipelineByPropertyName)] + [Parameter(ParameterSetName = 'BranchName')] [ValidateNotNullOrEmpty()] [string]$Path, @@ -447,13 +580,23 @@ function Remove-Worktree { $target = if ($PSCmdlet.ParameterSetName -eq 'Path') { Resolve-WorktreeTarget -Path $Path } else { - Resolve-WorktreeTarget -BranchName $BranchName + Resolve-WorktreeTarget -BranchName $BranchName -RepositoryPath $Path } if (-not $target) { return } + $repositoryContextPath = if ($PSCmdlet.ParameterSetName -eq 'BranchName') { $Path } else { $null } + $targetWorktrees = @(Get-Worktrees -Path $(if ($repositoryContextPath) { $repositoryContextPath } elseif (Test-Path -LiteralPath $target.Path -PathType Container) { $target.Path } else { $null })) + $gitContextPath = @($targetWorktrees | Where-Object { + (Test-Path -LiteralPath $_.Path -PathType Container) -and -not (Test-WorktreePathEquals -Left $_.Path -Right $target.Path) + } | Select-Object -First 1).Path + if (-not $gitContextPath) { + $gitContextPath = if ($repositoryContextPath) { Resolve-GitRepositoryPath -Path $repositoryContextPath } elseif (Test-Path -LiteralPath $target.Path -PathType Container) { $target.Path } else { $null } + } + if (-not $gitContextPath) { return } + $worktreePath = $target.Path if ($PSCmdlet.ShouldProcess($worktreePath, 'Remove worktree')) { - $removeArgs = @('worktree', 'remove') + $removeArgs = @('-C', $gitContextPath, 'worktree', 'remove') if ($Force) { $removeArgs += '--force' } $removeArgs += '--' $removeArgs += $worktreePath @@ -463,7 +606,7 @@ function Remove-Worktree { if ($target.Detached -or $target.Branch -eq '(detached)' -or -not $target.Branch) { Write-Warning 'The target worktree is detached; no branch was removed.' } elseif ($worktreeRemoved) { - git branch -D -- $target.Branch + git -C $gitContextPath branch -D -- $target.Branch } else { Write-Warning "Worktree removal failed; leaving branch '$($target.Branch)' in place." } @@ -481,8 +624,9 @@ function Set-Worktree { `Get-Worktrees`, so non-standard worktree locations are supported. Detached worktrees must be addressed by `-Path` because their branch label is ambiguous. .PARAMETER Path - The actual filesystem path of the worktree to change to. Accepts pipeline - input by property name from `Get-Worktrees` and related objects. + With -BranchName, directory inside the git working tree used to resolve the branch. + Without -BranchName, the actual filesystem path of the worktree to change to. + Accepts pipeline input by property name from `Get-Worktrees` and related objects. .EXAMPLE Set-Worktree -BranchName main Changes the current directory to the main branch worktree. @@ -493,11 +637,11 @@ function Set-Worktree { [CmdletBinding(DefaultParameterSetName = 'Path')] param( [Parameter(Mandatory, Position = 0, ParameterSetName = 'BranchName', ValueFromPipelineByPropertyName)] - [ValidateSet([WorktreeSetValuesGenerator])] [Alias('Branch')] [string]$BranchName, [Parameter(Mandatory, ParameterSetName = 'Path', ValueFromPipelineByPropertyName)] + [Parameter(ParameterSetName = 'BranchName')] [ValidateNotNullOrEmpty()] [string]$Path ) @@ -505,7 +649,7 @@ function Set-Worktree { $target = if ($PSCmdlet.ParameterSetName -eq 'Path') { Resolve-WorktreeTarget -Path $Path } else { - Resolve-WorktreeTarget -BranchName $BranchName + Resolve-WorktreeTarget -BranchName $BranchName -RepositoryPath $Path } if (-not $target) { return } diff --git a/modules/Shmuelie.Git/README.md b/modules/Shmuelie.Git/README.md index 93f1c2c..abea539 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 | -| `Add-Worktree` | Check out an existing branch to a worktree | +| `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 and check it out to a worktree from the current or `-Path` repository | +| `Add-Worktree` | Check out an existing branch to a worktree from the current or `-Path` repository | | `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 1a9766e..4dc84fa 100644 --- a/tests/Shmuelie.Git.Tests.ps1 +++ b/tests/Shmuelie.Git.Tests.ps1 @@ -100,6 +100,137 @@ Describe 'Add-Worktree' { } } + +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 -Path for worktree creation and removal without changing caller location' { + Invoke-Git @('-C', $script:pathTargetRepo, 'branch', 'existing-work') + $callerLocation = $null + + Push-Location $script:pathCallerRepo + try { + $callerLocation = (Get-Location).Path + $existingPath = Get-WorktreePath -BranchName existing-work -Path $script:pathTargetRepo + Add-Worktree -Path $script:pathTargetRepo -BranchName existing-work + Test-Path -LiteralPath $existingPath -PathType Container | Should -BeTrue + (Get-Worktrees -Path $script:pathTargetRepo).Path | Should -Contain $existingPath + + $newPath = Get-WorktreePath -BranchName explicit-new -Path $script:pathTargetRepo + New-Worktree -Path $script:pathTargetRepo -WorkName explicit-new -NoPrefix + Test-Path -LiteralPath $newPath -PathType Container | Should -BeTrue + (Get-Worktrees -Path $script:pathTargetRepo).Path | Should -Contain $newPath + + Invoke-Git @('-C', $script:pathTargetRepo, 'branch', 'remove-by-branch') + $removeByBranchPath = Get-WorktreePath -BranchName remove-by-branch -Path $script:pathTargetRepo + Add-Worktree -Path $script:pathTargetRepo -BranchName remove-by-branch + Remove-Worktree -BranchName remove-by-branch -Path $script:pathTargetRepo -Force + Test-Path -LiteralPath $removeByBranchPath | Should -BeFalse + + Remove-Worktree -Path $existingPath -Force + Test-Path -LiteralPath $existingPath | Should -BeFalse + (Get-Location).Path | Should -BeExactly $callerLocation + } finally { + Pop-Location + } + } + + It 'uses explicit -Path for Set-Worktree branch resolution' { + $original = (Get-Location).Path + Invoke-Git @('-C', $script:pathTargetRepo, 'branch', 'switch-target') + $targetPath = Get-WorktreePath -BranchName switch-target -Path $script:pathTargetRepo + Add-Worktree -Path $script:pathTargetRepo -BranchName switch-target + + Push-Location $script:pathCallerRepo + try { + Set-Worktree -BranchName switch-target -Path $script:pathTargetRepo + (Get-Location).Path | Should -BeExactly $targetPath + } finally { + Set-Location -LiteralPath $original + } + } + + 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