Guides · Version control
Excel VBA Version Control with Git: Export and Verify
Published · Tested with Microsoft 365 Excel 16.0.20430.20092 (64-bit), PowerShell 7.6.6, Git 2.52.0, Windows 11 Pro
Git can store a .xlsm, but to git it is a ZIP file: a one-character fix in a macro shows up as "binary files differ". Export the VBA to text and you get real diffs, reviews and history. This guide does it with two PowerShell scripts and adds a SHA256 manifest, so CI can tell when the exported code was edited by hand.
What git can track
The VBA editor exports each component of a project to its own file:
.bas- Standard modules.
.cls- Class modules, and the code behind
ThisWorkbookand each sheet. .frm+.frx- UserForms: the code and layout as text, the binary resources in the
.frx. Git stores the.frxbut cannot diff it.
Cell contents, formulas and formatting are not part of the export. To track what the workbook produces, add a golden file of its output: see Regression Test an Excel VBA Macro with a Golden File. And keep the delivered .xlsm as well: the text export is the source you review, not a backup you can open.
One setting to know about
Reading a VBA project from code requires Trust access to the VBA project object model (File › Options › Trust Center › Trust Center Settings › Macro Settings). It lets any program that automates Excel read and change VBA code, so it is a security decision, not a convenience switch: turn it on deliberately, on your own development machine, and consider turning it off again when you are done. The script below never changes it; when it is off, the script stops with a message saying so.
Step 1: export the modules
The example is the report.xlsm workbook from Run an Excel Macro from PowerShell with a Timeout, with one standard module, Report.
# export-vba.ps1: exports every VBA module of a workbook to text, plus a SHA256 manifest.
# Opens the workbook read-only with macros disabled. Needs "Trust access to the VBA
# project object model" (File > Options > Trust Center), which this script never changes.
param(
[Parameter(Mandatory)] [string] $Workbook,
[Parameter(Mandatory)] [string] $OutputDir
)
$ErrorActionPreference = 'Stop'
$extensions = @{ 1 = '.bas'; 2 = '.cls'; 3 = '.frm'; 100 = '.cls' } # module, class, form, sheet/workbook
Add-Type -Namespace Win32Tools -Name User32 -MemberDefinition @'
[DllImport("user32.dll")]
public static extern uint GetWindowThreadProcessId(System.IntPtr hWnd, out uint processId);
'@
# Export next to the output folder, then swap it in (same drive, so the move is one step).
$target = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputDir)
$staging = "$target.new-" + [guid]::NewGuid().ToString('N')
$excel = New-Object -ComObject Excel.Application
$excelPid = [uint32] 0
[void] [Win32Tools.User32]::GetWindowThreadProcessId([IntPtr] $excel.Hwnd, [ref] $excelPid)
$exported = $false
try {
New-Item -ItemType Directory -Path $staging | Out-Null
$excel.DisplayAlerts = $false
$excel.AutomationSecurity = 3 # msoAutomationSecurityForceDisable
$book = $excel.Workbooks.Open((Resolve-Path -LiteralPath $Workbook).Path, 0, $true)
try { $components = @($book.VBProject.VBComponents) }
catch { throw 'Cannot read the VBA project: "Trust access to the VBA project object model" is off.' }
foreach ($component in $components) {
$empty = $component.Type -eq 100 -and $component.CodeModule.CountOfLines -eq 0
if ($empty) { continue } # a sheet module with no code: nothing to track
$component.Export((Join-Path $staging ($component.Name + $extensions[[int] $component.Type])))
}
$book.Close($false)
$exported = $true
}
finally {
# A failed export leaves nothing behind next to the output folder.
if (-not $exported) { Remove-Item -LiteralPath $staging -Recurse -Force -ErrorAction SilentlyContinue }
$excel.Quit()
[void] [Runtime.InteropServices.Marshal]::ReleaseComObject($excel)
$process = Get-Process -Id $excelPid -ErrorAction SilentlyContinue
if ($process -and -not $process.WaitForExit(5000)) { $process.Kill(); [void] $process.WaitForExit(5000) }
}
# Manifest: one SHA256 per exported file, sorted, with no dates, so the same code gives the same file.
$files = @(Get-ChildItem -LiteralPath $staging -File | Sort-Object Name)
$manifest = [ordered]@{
workbook = Split-Path -Leaf $Workbook
files = @($files | ForEach-Object {
[ordered]@{ name = $_.Name; sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() }
})
}
$manifest | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path $staging 'vba-manifest.json') -Encoding utf8
# Replace the output folder as a whole: a module deleted from the workbook disappears from it too.
if (Test-Path -LiteralPath $target) { Remove-Item -LiteralPath $target -Recurse -Force }
Move-Item -LiteralPath $staging -Destination $target
Write-Output "Exported $($files.Count) file(s) to $OutputDir"
- Read-only, macros disabled. Exporting never runs the workbook's code, so an
Auto_OpenorWorkbook_Openmacro cannot fire. - The folder is replaced as a whole. Export into a new folder, then swap it in: a module deleted from the workbook also disappears from git, instead of lingering as a stale file.
- Empty sheet modules are skipped. Every sheet has a code module; exporting the empty ones only adds noise.
- The manifest has no dates. The same code gives the same
vba-manifest.json, so it only changes in git when a module does.
PS> ./export-vba.ps1 -Workbook report.xlsm -OutputDir vba
Exported 1 file(s) to vba
Step 2: keep the bytes stable
The VBA editor writes files with Windows line endings (CRLF), and the manifest hashes those exact bytes. If git converted them to LF on a Linux CI runner, every hash would fail. Add a .gitattributes at the root of the repository:
*.bas text eol=crlf
*.cls text eol=crlf
*.frm text eol=crlf
*.frx binary
With eol=crlf, git stores the files with LF and always checks them out with CRLF, whatever the platform's settings. We checked it by deleting Report.bas and checking it out again with core.eol=lf: git ls-files --eol reports i/lf w/crlf, and the manifest check passes.
One more thing about the bytes: the exports use the Windows code page (ANSI), not UTF-8. Accented characters in strings or comments may look wrong in a web diff. Leave the files as they are: re-saved as UTF-8, those characters would come back garbled when the file is imported into Excel again.
Step 3: commit and review a change
Commit the folder next to the workbook. Next time someone changes the macro, export again. For the workbook, git can only say that the bytes changed; the export shows the change itself. Here, a loop shortened by one row:
PS> git diff --stat
report.xlsm | Bin 16437 -> 16453 bytes
vba/Report.bas | 2 +-
vba/vba-manifest.json | 2 +-
3 files changed, 2 insertions(+), 2 deletions(-)
PS> git diff -U1 -- vba/Report.bas
@@ -16,3 +16,3 @@ Public Sub BuildReport()
lastRow = src.Cells(src.Rows.Count, 1).End(xlUp).Row
- For r = 2 To lastRow
+ For r = 2 To lastRow - 1
store = CStr(src.Cells(r, 2).Value)
That one line dropped the last row of every report. In a binary .xlsm diff it would have been invisible; in a pull request it is the first thing a reviewer sees.
Step 4: check the export against its manifest
The export is the record of what was in the workbook. If someone edits a .bas file in the repository instead of in Excel, the text and the workbook no longer match. This check catches it, without Excel:
# test-vba-manifest.ps1: checks an exported VBA folder against its manifest. No Excel needed.
# Exit codes: 0 every file matches, 1 a file changed, is missing or is not in the manifest, 2 no manifest.
param([Parameter(Mandatory)] [string] $VbaDir)
$ErrorActionPreference = 'Stop'
$manifestPath = Join-Path $VbaDir 'vba-manifest.json'
if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { Write-Output "ERROR: no vba-manifest.json in $VbaDir"; exit 2 }
$manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json
$problems = [Collections.Generic.List[string]]::new()
foreach ($entry in $manifest.files) {
$path = Join-Path $VbaDir $entry.name
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { $problems.Add("$($entry.name): missing"); continue }
$hash = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant()
if ($hash -ne $entry.sha256) { $problems.Add("$($entry.name): changed since the export") }
}
$listed = @($manifest.files.name) + 'vba-manifest.json'
foreach ($file in Get-ChildItem -LiteralPath $VbaDir -File | Where-Object Name -notin $listed) {
$problems.Add("$($file.Name): not in the manifest")
}
if ($problems.Count -eq 0) { Write-Output "OK: $(@($manifest.files).Count) file(s) match the manifest"; exit 0 }
Write-Output "FAIL: $($problems.Count) problem(s)"
$problems | ForEach-Object { Write-Output " - $_" }
exit 1
PS> ./test-vba-manifest.ps1 -VbaDir vba
OK: 1 file(s) match the manifest
PS> Add-Content vba/Report.bas "' quick fix"
PS> ./test-vba-manifest.ps1 -VbaDir vba
FAIL: 1 problem(s)
- Report.bas: changed since the export
A missing file and a file that is not in the manifest fail the same way. In GitHub Actions:
name: vba-check
on: pull_request
permissions:
contents: read
jobs:
manifest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: VBA export matches its manifest
shell: pwsh
run: ./tools/test-vba-manifest.ps1 -VbaDir vba
We tested the scripts on Windows; the manifest check uses only PowerShell 7 and .NET, but run it once on your CI image before relying on it.
What this does not do
- It does not put code back into a workbook. Importing modules from text into a
.xlsmis a separate step, and it deserves its own check: export the result again and compare. See Import VBA Modules and UserForms Without Breaking References. - It does not prove what you delivered. The manifest says the text matches the last export. To check a delivered file, export it again and compare the manifests.
- It does not merge workbooks. Two people editing the same
.xlsmstill conflict; the text export makes the conflict readable, not automatic.
Just want the scripts? The Scripts Pack · €12 has the code of this guide and the other guides ready to run, with examples and tests. The code on this page stays free.
The code on this page was written for this guide and tested on the versions above. The workbook and its data are invented.