Last active
April 16, 2025 07:37
-
-
Save andreabradpitto/bdf3aa03e3e10ded2a3126e1a4028900 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<# | |
.SYNOPSIS | |
Perform Git submodule update to any commit and/or branch. | |
.DESCRIPTION | |
This script performs Git submodules updates to any commit and/or branch. | |
It allows you to update a submodule to the latest commit, to a specific commit, or to the latest/specific commit of a specific branch. | |
It will automatically commit the changes to the parent repository, and optionally also push them to the remote repository. | |
.PARAMETER pathToLocalRepo | |
Mandatory parameter; it is the path to the root level of the local Git repository. | |
If not provided, the current working directory will be used. | |
.PARAMETER submodulePathList | |
Mandatory parameter; it is an array of all the submodules that should be updated. | |
Other submodules will be ignored. | |
If no submodule is provided, the script will exit with an error message. | |
.EXAMPLE | |
Use like this: .\gitSubmoduleUpdater.ps1 -pathToLocalRepo "C:\path\to\local\father\repo" -submodulePathList "submodule1", "submodule2", "submodule3/subpath3" | |
.NOTES | |
At least at the moment, is not suggested to have either staged or unstaged edits in the submodules that are going to be updated. | |
Proceeding with staged or unstaged edits in the submodules may lead to unexpected results. | |
Currently, a safeguard has been added to prevent the script from applying changes if there are staged or unstaged edits in the submodules. | |
[All the next commands should be executed in the parent local Git repository (preferably, in its root level - it is now mandatory as `.gitmodules` file is parsed).] | |
In case of issues, you can run git `submodule update --init --recursive` to reset the submodules to their original state. | |
For instance, this fixes the hard-to-remove unstaged change (new commits) warning that appears when you run `git status` in the CLI. | |
To only reset a specific submodule, you can run `git submodule update --init <submodule_path>`. | |
Here is the full command list to reset and update all submodules to the latest commit: | |
git submodule update --init --recursive | |
git submodule update --remote --recursive | |
git commit -am "Updated parent repo's submodule reference commit [skip ci]"; | |
git push | |
Extra tip: `git submodule update --remote <submodule_path>` updates the submodule to the latest commit of the branch specified in the `.gitignore` file. | |
#> | |
param ( | |
[Parameter(Mandatory = $true)] | |
[string]$pathToLocalRepo = $null, | |
[Parameter(Mandatory = $true)] | |
[string[]]$submodulePathList | |
) | |
if ([string]::IsNullOrEmpty($pathToLocalRepo)) { | |
$pathToLocalRepo = Get-Location | |
} | |
if (-not (Test-Path $pathToLocalRepo)) { | |
Write-Host "'$pathToLocalRepo' does not exist. Exiting." | |
exit 1 | |
} | |
if (-not $submodulePathList -or $submodulePathList.Count -eq 0) { | |
Write-Host "No submodule path list provided. Exiting." | |
exit 1 | |
} | |
$origWorkDir = Get-Location | |
$pathToLocalRepo = $pathToLocalRepo.Replace('\', '/') | |
$pathToLocalRepo = $pathToLocalRepo.Trim('/') | |
Set-Location "$pathToLocalRepo" | |
$gitRoot = git rev-parse --show-toplevel 2>$null | |
$isPathToLocalRepoAGitRoot = ($gitRoot -eq (Get-Location).Path.Replace('\', '/')) | |
if (-not $isPathToLocalRepoAGitRoot) { | |
Write-Host "'$pathToLocalRepo' is not the root of a Git repository. Exiting." | |
exit 1 | |
} | |
$gitModulesFileContent = Get-Content .gitmodules -Raw | |
$remoteUrl = git remote get-url origin | |
for ($idx = 0; $idx -lt $submodulePathList.Length; $idx++) { | |
# This is stored as it will compared later on | |
$wasThereSomethingToPush = git cherry 2>$null | |
$wasThereSomethingToPush = $wasThereSomethingToPush -join ' ' | |
$submodulePathList[$idx] = $submodulePathList[$idx].Replace('\', '/') | |
$submodulePathList[$idx] = $submodulePathList[$idx].Trim('/') | |
if (-not (Test-Path "$pathToLocalRepo/$($submodulePathList[$idx])")) { | |
Write-Warning "'$pathToLocalRepo/$($submodulePathList[$idx])' does not exist." | |
Set-Location "$pathToLocalRepo" | |
continue | |
} | |
# Regex explanation: | |
# - looks for [submodule "$submodulePathList[$idx]"] | |
# - collects all the characters until [submodule | |
# - extracts the value of "branch = ..." in a capture group | |
if ($gitModulesFileContent -match "(?s)\[submodule `"$($submodulePathList[$idx])`"\](?:(?!\[submodule).)*?branch\s*=\s*(?<branch>[^\r\n]+)") { | |
Write-Host "[.gitmodules file parse] Submodule '$($submodulePathList[$idx])' is currently bound to branch '$($Matches['branch'])'." | |
} | |
else { | |
Write-Warning "[.gitmodules file parse] No branch binding found for '$($submodulePathList[$idx])'." | |
} | |
# Get submodule's current commit SHA-1 using a regular expression | |
$currSubmodCommitSha = git submodule status $submodulePathList[$idx] | |
if ($currSubmodCommitSha -match '([0-9a-fA-F]{40})') { | |
$currSubmodCommitSha = $Matches[1] | |
Write-Host "Submodule '$($submodulePathList[$idx])' current commit is '$currSubmodCommitSha'." | |
} | |
else { | |
Write-Warning "Could not retrieve current commit for '$($submodulePathList[$idx])'." | |
} | |
Set-Location "$pathToLocalRepo/$($submodulePathList[$idx])" | |
if (-not (Test-Path ".git" -PathType Leaf)) { | |
Write-Warning "'$pathToLocalRepo/$($submodulePathList[$idx])' is not a Git submodule or it has not been initialized yet (`.git` file is missing). Skipping update of this submodule." | |
Set-Location "$pathToLocalRepo" | |
continue | |
} | |
git diff --cached --quiet # --quiet option suppresses output and returns 0 if there are no changes, 1 if there are changes. The return value is provided because --quiet implies --exit-code. | |
# That said, it looks like the return value logic is inverted. Indeed, these are the results of my tests on PowerShell: | |
#PS > Write-Host "--> git diff --quiet:" ; git diff --quiet ; $? ; Write-Host "--> git diff --cached --quiet:" ; git diff --cached --quiet ; $? | |
# [Test 1: repo without any edits] | |
#--> git diff --quiet: | |
#True | |
#--> git diff --cached --quiet: | |
#True | |
# | |
# [Test 2: repo with staged (cached) changes] | |
#--> git diff --quiet: | |
#True | |
#--> git diff --cached --quiet: | |
#False | |
# | |
# [Test 3: repo with unstaged changes] | |
#--> git diff --quiet: | |
#False | |
#--> git diff --cached --quiet: | |
#True | |
# | |
# [Test 4: repo with both staged and unstaged changes] | |
#--> git diff --quiet: | |
#False | |
#--> git diff --cached --quiet: | |
#False | |
# | |
# Hence I put the `-not` below: | |
$submoduleHasStagedChanges = -not $? # $? is a PowerShell automatic variable that contains the execution status of the last command. | |
# The command above checks if there are staged changes in the submodule. | |
# Alle the above also applies to the next instructions: | |
git diff --quiet | |
$submoduleHasUnstagedChanges = -not $? | |
if ($submoduleHasStagedChanges -or $submoduleHasUnstagedChanges) { | |
Write-Warning "Submodule '$($submodulePathList[$idx])' features local staged and/or unstaged changes. Consider pushing to remote to clean it status. Skipping update of this submodule." | |
Set-Location "$pathToLocalRepo" | |
continue | |
} | |
git fetch # --quiet | |
$userChoice = Read-Host "Update '$($submodulePathList[$idx])' submodule to...` | |
[1] latest commit,` | |
[2] specific commit,` | |
[3] latest commit of a specific branch,` | |
[4] specific commit of a specific branch" | |
switch ($userChoice) { | |
'1' { | |
Set-Location "$pathToLocalRepo" | |
git submodule update --remote $($submodulePathList[$idx]) # --merge? | |
Set-Location "$($submodulePathList[$idx])" | |
$latestCommitSha1 = git rev-parse --short HEAD | |
$commitMsg = "latest commit ($latestCommitSha1)." # ...of current branch | |
} | |
'2' { # NOTE: Specifying here a commit ID (SHA-1) belonging to another branch works anyway, but it is not recommended (`.gitmodules` file will not be updated). | |
$usrCommitSha1 = Read-Host "Enter the desired commit ID (SHA-1)" | |
if (-not (git rev-parse --verify $usrCommitSha1 2>$null)) { | |
Set-Location "$pathToLocalRepo" | |
Write-Warning "User-inseted '$usrCommitSha1' commit SHA-1 does not exist. Skipping update of this submodule." | |
continue | |
} | |
git checkout $usrCommitSha1 # Be wary that submodule checkouts leave the submodule in a detached HEAD state | |
$commitSha1 = git rev-parse --short HEAD | |
$commitMsg = "commit $commitSha1." # ...of current branch | |
} | |
'3' { | |
$usrBranchName = Read-Host "Enter the desired branch name" | |
if (-not (git branch --list --all $usrBranchName)) { # --all lists both local and remote branches | |
Set-Location "$pathToLocalRepo" | |
Write-Warning "User-inseted '$usrBranchName' branch does not exist (you may check submodule's branches with `git branch -a`). Skipping update of this submodule." | |
continue | |
} | |
#git checkout $usrBranchName # This is better replaced by below commands. | |
# The only difference is that the branch name is not set in .gitmodules file. | |
Set-Location "$pathToLocalRepo" | |
git submodule set-branch --branch $usrBranchName $($submodulePathList[$idx]) | |
git submodule update --remote $($submodulePathList[$idx]) # --merge? | |
Set-Location "$($submodulePathList[$idx])" | |
$latestCommitSha1 = git rev-parse --short HEAD | |
$commitMsg = "latest commit ($latestCommitSha1) of $usrBranchName branch." | |
} | |
'4' { # NOTE: Specifying here a commit ID (SHA-1) belonging to a branch that is not the one entered works anyway, but it is not recommended (`.gitmodules` file will not be updated accordingly). | |
$usrBranchName = Read-Host "Enter the desired branch name" | |
if (-not (git branch --list --all $usrBranchName)) { # --all lists both local and remote branches | |
Set-Location "$pathToLocalRepo" | |
Write-Warning "User-inseted '$usrBranchName' branch does not exist. Skipping update of this submodule." | |
continue | |
} | |
$usrCommitSha1 = Read-Host "Enter the desired commit ID (SHA-1)" | |
if (-not (git rev-parse --verify $usrCommitSha1 2>$null)) { | |
Set-Location "$pathToLocalRepo" | |
Write-Warning "User-inseted '$usrCommitSha1' commit SHA-1 does not exist (you may check submodule's branches with `git branch -a`). Skipping update of this submodule." | |
continue | |
} | |
#git checkout -b $usrBranchName $usrCommitSha1 # This is better replaced by below commands. | |
# The only difference is that the branch name is not set in .gitmodules file. | |
Set-Location "$pathToLocalRepo" | |
git submodule set-branch --branch $usrBranchName $($submodulePathList[$idx]) | |
#git submodule update --remote $($submodulePathList[$idx]) # --merge? | |
Set-Location "$($submodulePathList[$idx])" | |
git checkout $usrCommitSha1 # Be wary that submodule checkouts leave the submodule in a detached HEAD state | |
$commitSha1 = git rev-parse --short HEAD | |
$commitMsg = "commit $commitSha1 of $usrBranchName branch." | |
} | |
default { | |
Write-Host "Invalid user input. Exiting." | |
Set-Location "$origWorkDir" | |
exit 1 | |
} | |
} | |
Set-Location "$pathToLocalRepo" | |
git add "$pathToLocalRepo/$($submodulePathList[$idx])" | |
# Detect if .gitmodules file has been modified (this is due to user choice '3' or '4') | |
$changesToGitModulesFile = git diff --name-only -- .\.gitmodules | |
if (-not [string]::IsNullOrEmpty($changesToGitModulesFile)) { | |
git add .\.gitmodules | |
} | |
git commit -m "Update submodule to $commitMsg" | |
$isThereSomethingToPush = git cherry 2>$null | |
$isThereSomethingToPush = $isThereSomethingToPush -join ' ' # This is done to easily compare a single string instead of an array | |
# Ask the user to push the changes to the remote repository, but only if local repo status has been modified by this script | |
if (-not ($isThereSomethingToPush -eq $wasThereSomethingToPush)) { | |
Write-Host "Push '$pathToLocalRepo/$($submodulePathList[$idx])' submodule update" | |
$pushAnswer = Read-Host "to remote parent repository ($remoteUrl)? [y/N]" | |
if ($pushAnswer -match '^(y|Y|yes|Yes)$') { | |
git push origin | |
} | |
} | |
} | |
Set-Location "$origWorkDir" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment