Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions modules/Shmuelie.Git/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 15 additions & 6 deletions modules/Shmuelie.Git/Public/Find-StaleBranch.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>/*. Defaults to the current
git user name derived from user.email config.
Expand Down Expand Up @@ -44,6 +46,10 @@ function Find-StaleBranch {
param(
[string]$Remote = 'origin',

[Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)]
[Alias('RepositoryPath', 'RepoPath')]
[string]$Path,

[string]$User,

[switch]$IncludePrStatus,
Expand All @@ -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._/-]+$'
Expand All @@ -70,17 +79,17 @@ 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]
}
}
$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) {
Expand All @@ -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
Expand All @@ -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
}
Expand All @@ -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/(?<org>[^/]+)/(?<project>[^/]+)/_git/(?<repo>.+)$' -or
$gitUrl -match '(?<org>[^/]+)\.visualstudio\.com/(?:DefaultCollection/)?(?<project>[^/]+)/_git/(?<repo>.+)$') {
$org = & $decodeRemoteUrlComponent $Matches['org']
Expand Down
32 changes: 16 additions & 16 deletions modules/Shmuelie.Git/Public/Get-GitStatusSummary.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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) {
Expand All @@ -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)
Expand Down Expand Up @@ -253,7 +255,5 @@ function Get-GitStatusSummary {
HasChanges = ($hasIndex -or $hasWorking -or $hasUntracked -or $hasConflicts)
StatusString = $sb.ToString()
}
} finally {
if ($pushed) { Pop-Location }
}
}
25 changes: 18 additions & 7 deletions modules/Shmuelie.Git/Public/Sync-GitRemote.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -67,6 +69,10 @@ function Sync-GitRemote {
[ValidateNotNullOrEmpty()]
[string]$Remote,

[Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)]
[Alias('RepositoryPath', 'RepoPath')]
[string]$Path,

[switch]$NoPrune,

[hashtable]$GitHubAccountMap,
Expand All @@ -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)
}
Expand All @@ -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 }
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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"
}
}
Expand All @@ -234,6 +244,7 @@ function Sync-GitRemote {
}
# Skip "Fetching <remote>" and "From <url>" header lines
}
}
}

function Get-GitFetchParseEnvironment {
Expand Down
Loading