|
#!/usr/bin/env pwsh |
|
# |
|
"Update_Submodules.ps1: Working directory: $(Get-Location)" |
|
"" |
|
#"Path environment variable:`n$($env:PATH -split ';' -join ";`n")" |
|
#"" |
|
|
|
# update_submodules.ps1 - an interactive helper to update git submodules in a repo. |
|
# Latest version can be obtained from http://gist.github.com/f-steff |
|
# License: MIT. This script is MIT licensed. |
|
# version 0.2 - 2026-01-15 |
|
|
|
# Track whether we've already prompted for optional git credential config |
|
$script:GitConfigPrompted = $false |
|
|
|
# Apply the recommended git credential configuration (global) |
|
function Apply-RecommendedGitConfig { |
|
"Applying recommended git credential config (global)." |
|
git config --global --replace-all credential.helper manager |
|
git config --global --replace-all credential.useHttpPath true |
|
} |
|
|
|
function Prompt-ApplyGitConfig { |
|
param ( |
|
[string]$CommandName |
|
) |
|
|
|
if ($script:GitConfigPrompted) { |
|
return $false |
|
} |
|
|
|
$script:GitConfigPrompted = $true |
|
$choice = Read-Host "Apply recommended git credential config (global, Windows Credential Manager) and retry $CommandName? (y/N)" |
|
if ($choice -match '^[yY]$') { |
|
Apply-RecommendedGitConfig |
|
return $true |
|
} |
|
|
|
return $false |
|
} |
|
|
|
# Check for exit input (X/x) without terminating the process |
|
function Exit-OnRequest { |
|
param ( |
|
[string]$InputValue |
|
) |
|
|
|
if ($null -eq $InputValue) { |
|
return $false |
|
} |
|
|
|
if ($InputValue.Trim() -match '^[xX]$') { |
|
Write-Host "Exiting." |
|
return $true |
|
} |
|
|
|
return $false |
|
} |
|
|
|
# Update a single submodule by path |
|
function Update-Submodule { |
|
param ( |
|
[string]$path |
|
) |
|
|
|
# Ensure the path exists before proceeding |
|
if (-not (Test-Path $path)) { |
|
Write-Host "Path not found: $path" |
|
return |
|
} |
|
|
|
Push-Location $path |
|
try { |
|
# Check for uncommitted changes in the working directory and submodules |
|
$uncommittedChanges = git status --porcelain |
|
if ($uncommittedChanges) { |
|
Write-Host "There are uncommitted changes in the working directory or submodules. Aborting updating current submodule." |
|
return $false |
|
} |
|
|
|
# Capture the starting commit for default selection behavior |
|
$startingCommit = git rev-parse HEAD |
|
$startingCommitShort = git rev-parse --short HEAD |
|
$startingCommitSubject = git show -s --format=%s $startingCommit |
|
|
|
# Fetching all branches |
|
git fetch --all --progress --prune --recurse-submodules=on-demand |
|
if ($LASTEXITCODE -ne 0) { |
|
if (Prompt-ApplyGitConfig "git fetch") { |
|
git fetch --all --progress --prune --recurse-submodules=on-demand |
|
if ($LASTEXITCODE -ne 0) { |
|
Write-Host "git fetch failed; using cached refs." |
|
} |
|
} else { |
|
Write-Host "git fetch failed; using cached refs." |
|
} |
|
} |
|
|
|
# Determine current branch (or detached state) |
|
$currentBranch = git symbolic-ref --quiet --short HEAD |
|
if ($null -eq $currentBranch) { |
|
$detachedHeadCommit = git rev-parse --short HEAD |
|
" ** Current submodule is in DETACHED HEAD state at $detachedHeadCommit" |
|
" ** Original branch name is unknown." |
|
$currentBranch = "DETACHED" |
|
} |
|
$startingBranch = $currentBranch |
|
"" |
|
"Select among optional branches:" |
|
# Listing branches (most recent first) |
|
$branchRefLines = @(git for-each-ref --sort=-committerdate --format="%(refname)|%(refname:short)" refs/heads refs/remotes) |
|
$localBranches = @() |
|
foreach ($line in $branchRefLines) { |
|
if ([string]::IsNullOrWhiteSpace($line)) { |
|
continue |
|
} |
|
$parts = $line -split '\|', 2 |
|
if ($parts.Length -lt 2) { |
|
continue |
|
} |
|
$fullRef = $parts[0] |
|
$shortRef = $parts[1] |
|
if ($fullRef -like 'refs/heads/*') { |
|
$localBranches += $shortRef |
|
} |
|
} |
|
|
|
$branches = @() |
|
foreach ($line in $branchRefLines) { |
|
if ([string]::IsNullOrWhiteSpace($line)) { |
|
continue |
|
} |
|
$parts = $line -split '\|', 2 |
|
if ($parts.Length -lt 2) { |
|
continue |
|
} |
|
$fullRef = $parts[0] |
|
$shortRef = $parts[1] |
|
if ($fullRef -like 'refs/remotes/*/HEAD') { |
|
continue |
|
} |
|
$isRemote = $fullRef -like 'refs/remotes/*' |
|
if ($isRemote) { |
|
$localName = $shortRef -replace '^[^/]+/', '' |
|
if ($localBranches -contains $localName) { |
|
continue |
|
} |
|
} |
|
$branches += [pscustomobject]@{ |
|
Display = $shortRef |
|
Ref = $shortRef |
|
IsRemote = $isRemote |
|
} |
|
} |
|
|
|
$currentBranchIndex = -1 |
|
for ($i = 0; $i -lt $branches.Length; $i++) { |
|
if ($branches[$i].Display -eq $currentBranch) { |
|
$currentBranchIndex = $i |
|
break |
|
} |
|
} |
|
$currentBranchEntry = $null |
|
if ($currentBranchIndex -ge 0) { |
|
$currentBranchEntry = $branches[$currentBranchIndex] |
|
} |
|
|
|
# Branch selection (paged) |
|
$pageSize = 8 |
|
$pageIndex = 0 |
|
while ($true) { |
|
$pageStart = $pageIndex * $pageSize |
|
$pageBranches = @() |
|
$pageEnd = $pageStart - 1 |
|
if ($branches.Length -gt 0 -and $pageStart -lt $branches.Length) { |
|
$pageEnd = [Math]::Min($branches.Length - 1, $pageStart + $pageSize - 1) |
|
$pageBranches = $branches[$pageStart..$pageEnd] |
|
} |
|
|
|
$displayBranches = @() |
|
if ($pageBranches.Length -gt 0) { |
|
$displayBranches += $pageBranches |
|
} |
|
|
|
$currentInPage = $false |
|
foreach ($branch in $pageBranches) { |
|
if ($branch.Display -eq $currentBranch) { |
|
$currentInPage = $true |
|
break |
|
} |
|
} |
|
|
|
if (-not $currentInPage) { |
|
if ($currentBranchIndex -ge 0) { |
|
if ($currentBranchIndex -gt $pageEnd) { |
|
$displayBranches += $currentBranchEntry |
|
} |
|
} elseif ($currentBranch -eq "DETACHED") { |
|
$displayBranches += [pscustomobject]@{ |
|
Display = $currentBranch |
|
Ref = $currentBranch |
|
IsRemote = $false |
|
} |
|
} |
|
} |
|
|
|
Write-Host " 0: Keep current branch (Enter)" |
|
for ($i = 0; $i -lt $displayBranches.Length; $i++) { |
|
$display = $displayBranches[$i].Display |
|
if ($display -eq $currentBranch) { |
|
Write-Host " $($i + 1): * $display" |
|
} else { |
|
Write-Host " $($i + 1): $display" |
|
} |
|
} |
|
if (($pageStart + $pageSize) -lt $branches.Length) { |
|
Write-Host " N: Next 8 branches" |
|
} |
|
Write-Host " X: Exit" |
|
|
|
# User selects branch |
|
$selectedBranchIndex = Read-Host "Select branch number (current: $currentBranch, Enter to keep)" |
|
if (Exit-OnRequest $selectedBranchIndex) { |
|
return $true |
|
} |
|
if ([string]::IsNullOrWhiteSpace($selectedBranchIndex) -or $selectedBranchIndex -eq '0') { |
|
"Current branch selected." |
|
break |
|
} |
|
|
|
if ($selectedBranchIndex -match '^[nN]$') { |
|
if (($pageStart + $pageSize) -lt $branches.Length) { |
|
$pageIndex++ |
|
} else { |
|
Write-Host "No more branches to show." |
|
} |
|
continue |
|
} |
|
|
|
if ($selectedBranchIndex -match '^\d+$') { |
|
$selectedIndex = [int]$selectedBranchIndex |
|
if ($selectedIndex -ge 1 -and $selectedIndex -le $displayBranches.Length) { |
|
$selectedBranch = $displayBranches[$selectedIndex - 1] |
|
if ($selectedBranch.Display -eq $currentBranch) { |
|
"Current branch selected." |
|
break |
|
} |
|
|
|
if ($selectedBranch.IsRemote) { |
|
$localName = $selectedBranch.Display -replace '^[^/]+/', '' |
|
if ($localBranches -contains $localName) { |
|
git checkout $localName |
|
} else { |
|
git checkout -t $selectedBranch.Ref |
|
} |
|
} else { |
|
git checkout $selectedBranch.Ref |
|
} |
|
} else { |
|
Write-Host "Invalid branch selection. Keeping current branch." |
|
} |
|
break |
|
} else { |
|
Write-Host "Invalid branch selection. Keeping current branch." |
|
break |
|
} |
|
} |
|
|
|
# Detect whether a branch change occurred |
|
$currentBranchAfter = git symbolic-ref --quiet --short HEAD |
|
if ($null -eq $currentBranchAfter) { |
|
$currentBranchAfter = "DETACHED" |
|
} |
|
$branchChanged = $currentBranchAfter -ne $startingBranch |
|
|
|
"" |
|
# Pull from upstream when possible (and use it as the commit log source) |
|
$headRef = git rev-parse --abbrev-ref HEAD |
|
$logRef = "HEAD" |
|
if ($headRef -eq "HEAD") { |
|
"Skipping git pull because HEAD is detached." |
|
} else { |
|
$logRef = $headRef |
|
$upstreamRef = git rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>$null |
|
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($upstreamRef)) { |
|
"No upstream configured for $headRef. Skipping git pull." |
|
} else { |
|
$upstreamRemoteRef = "refs/remotes/$upstreamRef" |
|
git show-ref --verify --quiet $upstreamRemoteRef |
|
if ($LASTEXITCODE -ne 0) { |
|
"Upstream $upstreamRef not found on remote. Skipping git pull." |
|
} else { |
|
$logRef = $upstreamRef |
|
"Synchronizing remote with local git ($upstreamRef)." |
|
git pull |
|
if ($LASTEXITCODE -ne 0) { |
|
if (Prompt-ApplyGitConfig "git pull") { |
|
git pull |
|
if ($LASTEXITCODE -ne 0) { |
|
Write-Host "git pull failed; skipping." |
|
} |
|
} else { |
|
Write-Host "git pull failed; skipping." |
|
} |
|
} |
|
} |
|
} |
|
} |
|
"" |
|
|
|
# Current commit info for selection labels |
|
$currentCommit = git rev-parse HEAD |
|
$currentCommitShort = git rev-parse --short HEAD |
|
$currentCommitSubject = git show -s --format=%s $currentCommit |
|
|
|
# Decide which commit should be starred and defaulted |
|
$preferredCommit = $startingCommit |
|
$preferredCommitShort = $startingCommitShort |
|
$preferredCommitSubject = $startingCommitSubject |
|
$preferredCommitLabel = "start commit" |
|
$extraCommitLabel = "starting commit" |
|
$commitSuffix = "(start)" |
|
if ($branchChanged) { |
|
$preferredCommit = $currentCommit |
|
$preferredCommitShort = $currentCommitShort |
|
$preferredCommitSubject = $currentCommitSubject |
|
$preferredCommitLabel = "current branch commit" |
|
$extraCommitLabel = "current branch commit" |
|
$commitSuffix = "(current)" |
|
} |
|
|
|
# Commit selection (paged) |
|
$commitPageSize = 9 |
|
$commitPageIndex = 0 |
|
while ($true) { |
|
$commitSkip = $commitPageIndex * $commitPageSize |
|
# List a page of commits from the branch tip (or upstream) |
|
$commitLines = @(git log -n $commitPageSize --skip $commitSkip $logRef --pretty=format:"%H|%h|%s") |
|
$pageCommits = @() |
|
foreach ($line in $commitLines) { |
|
if ([string]::IsNullOrWhiteSpace($line)) { |
|
continue |
|
} |
|
$parts = $line -split '\|', 3 |
|
if ($parts.Length -ge 3) { |
|
$pageCommits += [pscustomobject]@{ |
|
Full = $parts[0] |
|
Short = $parts[1] |
|
Subject = $parts[2] |
|
} |
|
} |
|
} |
|
|
|
if ($pageCommits.Length -eq 0) { |
|
Write-Host "No commits found." |
|
break |
|
} |
|
|
|
$preferredCommitIndex = -1 |
|
for ($i = 0; $i -lt $pageCommits.Length; $i++) { |
|
if ($pageCommits[$i].Full -eq $preferredCommit) { |
|
$preferredCommitIndex = $i |
|
break |
|
} |
|
} |
|
$preferredCommitInPage = $preferredCommitIndex -ge 0 |
|
|
|
$displayCommits = @() |
|
$displayCommits += $pageCommits |
|
if (-not $preferredCommitInPage) { |
|
# Place the preferred commit at the top (newer) or bottom (older) when off-page |
|
$preferredEntry = [pscustomobject]@{ |
|
Full = $preferredCommit |
|
Short = $preferredCommitShort |
|
Subject = "$preferredCommitSubject $commitSuffix" |
|
} |
|
|
|
$placeAtTop = $false |
|
if ($pageCommits.Length -gt 0) { |
|
git merge-base --is-ancestor $pageCommits[0].Full $preferredCommit | Out-Null |
|
if ($LASTEXITCODE -eq 0) { |
|
$placeAtTop = $true |
|
} |
|
} |
|
|
|
if ($displayCommits.Length -ge $commitPageSize) { |
|
$displayCommits = $displayCommits[0..($commitPageSize - 2)] |
|
} |
|
|
|
if ($placeAtTop) { |
|
$displayCommits = @($preferredEntry) + $displayCommits |
|
$preferredCommitIndex = 0 |
|
} else { |
|
$displayCommits += $preferredEntry |
|
$preferredCommitIndex = $displayCommits.Length - 1 |
|
} |
|
} |
|
|
|
$defaultCommitIndex = $preferredCommitIndex + 1 |
|
|
|
# Check if another page exists |
|
$hasMoreCommits = $false |
|
$nextCommitLines = @(git log -n 1 --skip ($commitSkip + $commitPageSize) $logRef --pretty=format:"%H") |
|
if ($nextCommitLines.Length -gt 0 -and -not [string]::IsNullOrWhiteSpace($nextCommitLines[0])) { |
|
$hasMoreCommits = $true |
|
} |
|
|
|
Write-Host "Select from commits (page $($commitPageIndex + 1))" |
|
if (-not $preferredCommitInPage) { |
|
Write-Host " (includes $extraCommitLabel)" |
|
} |
|
Write-Host " 0: Enter a specific commit hash" |
|
for ($i = 0; $i -lt $displayCommits.Length; $i++) { |
|
if ($displayCommits[$i].Full -eq $preferredCommit) { |
|
Write-Host " $($i + 1): * $($displayCommits[$i].Short) - $($displayCommits[$i].Subject)" |
|
} else { |
|
Write-Host " $($i + 1): $($displayCommits[$i].Short) - $($displayCommits[$i].Subject)" |
|
} |
|
} |
|
if ($hasMoreCommits) { |
|
Write-Host " N: Next 9 commits" |
|
} |
|
Write-Host " X: Exit" |
|
|
|
# User selects commit or enters hash |
|
$commitSelection = Read-Host "Enter your choice (0-$($displayCommits.Length)) - Default is option $defaultCommitIndex ($preferredCommitLabel $preferredCommitShort, Enter to keep)." |
|
if (Exit-OnRequest $commitSelection) { |
|
return $true |
|
} |
|
if ([string]::IsNullOrWhiteSpace($commitSelection)) { |
|
$commitSelection = [string]$defaultCommitIndex |
|
} |
|
if ($commitSelection -match '^[nN]$') { |
|
if ($hasMoreCommits) { |
|
$commitPageIndex++ |
|
} else { |
|
Write-Host "No more commits to show." |
|
} |
|
continue |
|
} |
|
if ($commitSelection -eq '0') { |
|
$selectedCommit = Read-Host "Enter the commit hash" |
|
if (Exit-OnRequest $selectedCommit) { |
|
return $true |
|
} |
|
$headRef = git rev-parse --abbrev-ref HEAD |
|
if ($headRef -eq "HEAD") { |
|
git checkout $selectedCommit |
|
} else { |
|
git reset --hard $selectedCommit |
|
} |
|
break |
|
} |
|
if ($commitSelection -match '^\d+$') { |
|
$selectedCommitIndex = [int]$commitSelection - 1 |
|
if ($selectedCommitIndex -ge 0 -and $selectedCommitIndex -lt $displayCommits.Length) { |
|
$selectedCommit = $displayCommits[$selectedCommitIndex].Full |
|
$headRef = git rev-parse --abbrev-ref HEAD |
|
if ($headRef -eq "HEAD") { |
|
git checkout $selectedCommit |
|
} else { |
|
git reset --hard $selectedCommit |
|
} |
|
} else { |
|
Write-Host "Invalid commit selection. Keeping HEAD." |
|
} |
|
break |
|
} |
|
|
|
Write-Host "Invalid commit selection. Keeping HEAD." |
|
break |
|
} |
|
} finally { |
|
Pop-Location |
|
} |
|
|
|
return $false |
|
} |
|
|
|
|
|
"Parsing .gitmodules file to find submodules to optionally be updated" |
|
$submodules = git config --file .gitmodules --get-regexp path | ForEach-Object { $_.Split()[1] } |
|
|
|
# Updating each submodule |
|
$exitRequested = $false |
|
foreach ($submodule in $submodules) { |
|
"" |
|
Write-Host "*** Updating submodule: $submodule" |
|
if (Update-Submodule $submodule) { |
|
$exitRequested = $true |
|
break |
|
} |
|
} |
|
"" |
|
"Current submodules:" |
|
git submodule status |
|
if ($exitRequested) { |
|
return |
|
} |