Skip to content

Instantly share code, notes, and snippets.

@f-steff
Last active January 15, 2026 11:36
Show Gist options
  • Select an option

  • Save f-steff/3b1223c9f99cbdcb9ddf631a0f4e96ad to your computer and use it in GitHub Desktop.

Select an option

Save f-steff/3b1223c9f99cbdcb9ddf631a0f4e96ad to your computer and use it in GitHub Desktop.
update_submodules.ps1 - an interactive helper to update git submodules in a repo.

update_submodules.ps1

Documentation for update_submodules.ps1, an interactive helper to update git submodules in this repo.

Latest version can be obtained from http://gist.github.com/f-steff

License: MIT. This script is MIT licensed.

Quick start

Run from the repo root:

pwsh -File .\update_submodules.ps1

What the script does

  • Reads submodule paths from .gitmodules.
  • For each submodule that's not dirty:
    • Verifies the path exists.
    • Fetches remotes (optional prompt to apply git credential config on failure).
    • Shows a branch picker (paged, most recent first).
    • Optionally checks out a branch (local or remote).
    • Pulls if an upstream is configured and still exists.
    • Shows a commit picker (defaults to the starting commit unless you switched branches).
    • Uses git reset --hard <commit> when on a branch, or git checkout <commit> when detached.
  • Prints git submodule status at the end (even if the user exits early).

Branch selection

  • Sorted by most recent commit time (committer date).
  • The current branch is marked with * and appears by recency; if it is older than the branches on the current page, it is appended as the last option.
  • 0 or Enter keeps the current branch.
  • N shows the next 8 branches.
  • X exits the script and restores the original directory.
  • Remote branches that already have a local branch are hidden.
  • Remote HEAD pointers are hidden.

Commit selection

  • Lists commits in pages of 9 from the branch tip (upstream if configured, otherwise local).
  • If you stay on the same branch, the starting commit is marked with * and is the default.
  • If you switch branches, the current branch HEAD is marked with * and is the default.
  • If the default commit is not in the current page, it is shown at the top (newer) or bottom (older), with (start) or (current), replacing the oldest entry to keep 9 items.
  • N shows the next 9 (older) commits.
  • 0 allows entering a specific hash.
  • X exits the script and restores the original directory.

Git credential prompt

If git fetch or git pull fails, the script prompts to apply this global config:

  • credential.helper = manager
  • credential.useHttpPath = true

It only prompts once per script run.

Example output (happy path)

Update_Submodules.ps1: Working directory: C:\Projects\DCmax

Parsing .gitmodules file to find submodules to optionally be updated

*** Updating submodule: lib_common

Select among optional branches:
   0: Keep current branch (Enter)
   1: * main
   2:   feature/can-fix
   3:   origin/release/1.2
   4:   origin/hotfix/uart-timeout
   N: Next 8 branches
   X: Exit
Select branch number (current: main, Enter to keep):
Current branch selected.

Synchronizing remote with local git (origin/main).
Already up to date.

Select from commits (page 1)
   0: Enter a specific commit hash
   1: * a1b2c3d - Fix PWM tuning
   2:   b2c3d4e - Add fault log
   3:   c3d4e5f - Update docs
   4:   d4e5f6a - Cleanup
   5:   e5f6a7b - Refactor
   6:   f6a7b8c - Add tests
   7:   0123abc - Bump version
   8:   1234bcd - Init
   9:   2345cde - WIP
   N: Next 9 commits
   X: Exit
Enter your choice (0-9) - Default is option 1 (start commit a1b2c3d, Enter to keep):
HEAD is checked out

*** Updating submodule: lib_hal
...

Current submodules:
 9f1c2d3 lib_common (heads/main)
 6a7b8c9 lib_hal (heads/main)

Example output (detached HEAD)

*** Updating submodule: lib_chip
   ** Current submodule is in DETACHED HEAD state at 2c3d4e5
   ** Original branch name is unknown.

Select among optional branches:
   0: Keep current branch (Enter)
   1:   main
   2:   origin/feature/pwm
   3: * DETACHED
   X: Exit
Select branch number (current: DETACHED, Enter to keep):
Current branch selected.

Skipping git pull because HEAD is detached.

Example output (paging branches)

Select among optional branches:
   0: Keep current branch (Enter)
   1:   feature/alpha
   2:   feature/beta
   3:   feature/gamma
   4:   feature/delta
   5:   feature/epsilon
   6:   feature/zeta
   7:   feature/eta
   8:   feature/theta
   9: * release/1.0
   N: Next 8 branches
   X: Exit
Select branch number (current: release/1.0, Enter to keep): N

Select among optional branches:
   0: Keep current branch (Enter)
   1:   feature/iota
   2: * release/1.0
   3:   feature/kappa
   4:   origin/release/1.1
   5:   origin/release/0.9
   X: Exit
Select branch number (current: release/1.0, Enter to keep):
Current branch selected.

Example output (paging commits)

Select from commits (page 1)
   (includes starting commit)
   0: Enter a specific commit hash
   1:   1a2b3c4 - Latest change
   2:   2b3c4d5 - Fix warning
   3:   3c4d5e6 - Update docs
   4:   4d5e6f7 - Refactor
   5:   5e6f7a8 - Cleanup
   6:   6f7a8b9 - Improve logs
   7:   7a8b9c0 - Adjust defaults
   8:   8b9c0d1 - Bump version
   9: * f1e2d3c - Old stable (start)
   N: Next 9 commits
   X: Exit
Enter your choice (0-9) - Default is option 9 (start commit f1e2d3c, Enter to keep): N

Select from commits (page 2)
   0: Enter a specific commit hash
   1:   a0b1c2d - Older change
   2: * f1e2d3c - Old stable
   3:   c2d3e4f - Initial setup
   X: Exit
Enter your choice (0-3) - Default is option 2 (start commit f1e2d3c, Enter to keep):

Example output (starting commit not in last 9)

Select from commits (page 1)
   (includes starting commit)
   0: Enter a specific commit hash
   1:   111aaaa - Latest change
   2:   222bbbb - Fix
   3:   333cccc - Cleanup
   4:   444dddd - Update
   5:   555eeee - Refactor
   6:   666ffff - Add tests
   7:   7771111 - Enable build
   8:   8882222 - Init
   9: * abc1234 - Old stable (start)
   X: Exit
Enter your choice (0-9) - Default is option 9 (start commit abc1234, Enter to keep):

Example output (git config prompt on failure)

git fetch --all --progress --prune --recurse-submodules=on-demand
fatal: Authentication failed
Apply recommended git credential config (global, Windows Credential Manager) and retry git fetch? (y/N): y
Applying recommended git credential config (global).

Example output (no upstream or missing upstream)

No upstream configured for feature/local-only. Skipping git pull.
Upstream origin/feature/old-branch not found on remote. Skipping git pull.

Example output (dirty worktree)

There are uncommitted changes in the working directory or submodules. Aborting updating current submodule.

Example output (exit)

Select from commits (page 1)
   0: Enter a specific commit hash
   1: * a1b2c3d - Fix PWM tuning
   2:   b2c3d4e - Add fault log
   N: Next 9 commits
   X: Exit
Enter your choice (0-2) - Default is option 1 (start commit a1b2c3d, Enter to keep): X
Exiting.

Example output (invalid selection)

Select branch number (current: main, Enter to keep): 99
Invalid branch selection. Keeping current branch.

Example output (missing path)

Path not found: lib_missing
#!/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
}
Version History
version 0.1 - 2024-02-15 Initial version. Used on many projects. (Private)
version 0.2 - 2026-01-15 Polished and fixed some edge cases situations. (First Public release)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment