Last active
November 7, 2025 21:06
-
-
Save Wind010/1a5e5bff107a2a47f93e0cf46cc95e84 to your computer and use it in GitHub Desktop.
Extracts all package versions to Directory.Packages.props and leaves package references in the .csprojs recursively.
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
| # Extracts all package versions to Directory.Packages.props and leaves package references in the .csprojs recursively. | |
| # Will accommediate for multi-target builds with package references that are conditional to TargetFramework. | |
| # Usage: .\Convert-To-DirectoryPackages.ps1 -RootPath "C:\path_to_root_solution"." | |
| # The PreserveAssets parameter will leave the PrivateAssets and IncludedAssets elements in each csproj. | |
| # Those elements will be added as attributes to Directory.Packages.props always. | |
| param( | |
| [string]$RootPath = ".", | |
| [string]$OutputFile = "Directory.Packages.props", | |
| [bool]$PreserveAssets = $false | |
| ) | |
| function Compare-NuGetVersion { | |
| param( | |
| [string]$a, | |
| [string]$b | |
| ) | |
| try { | |
| $va = [version]$a | |
| $vb = [version]$b | |
| return $va.CompareTo($vb) | |
| } | |
| catch { | |
| return [string]::Compare($a, $b, $true) | |
| } | |
| } | |
| function Convert-ToDirectoryPackages { | |
| param( | |
| [string]$RootPath, | |
| [string]$OutputFile, | |
| [bool]$PreserveAssets | |
| ) | |
| Write-Host "🧑💻 Scanning project files under '$RootPath' ..." -ForegroundColor Blue | |
| $packageVersions = @{} | |
| $conditionalPackages = @{} | |
| $projectFiles = Get-ChildItem -Path $RootPath -Include *.csproj, Directory.Build.props -Recurse | |
| foreach ($file in $projectFiles) { | |
| Write-Host "`tProcessing '$file' ..." | |
| [xml]$xml = Get-Content $file.FullName | |
| $itemGroups = $xml.Project.ItemGroup | Where-Object { $_.PackageReference } | |
| foreach ($itemGroup in $itemGroups) { | |
| $condition = $itemGroup.Condition | |
| foreach ($pkg in $itemGroup.PackageReference) { | |
| $id = $pkg.Include | |
| $version = $pkg.Version | |
| if (-not $id) { continue } | |
| # Detect PrivateAssets/IncludeAssets | |
| $privateAssets = $pkg.PrivateAssets | |
| $includeAssets = $pkg.IncludeAssets | |
| $extraAttrs = @{} | |
| if ($privateAssets) { $extraAttrs["PrivateAssets"] = $privateAssets } | |
| if ($includeAssets) { $extraAttrs["IncludeAssets"] = $includeAssets } | |
| if ($version) { | |
| # Track max version globally, with extra attributes | |
| if ($packageVersions.ContainsKey($id)) { | |
| if ((Compare-NuGetVersion $version $packageVersions[$id].Version) -gt 0) { | |
| $packageVersions[$id] = @{ Version = $version; Extra = $extraAttrs } | |
| } | |
| } else { | |
| $packageVersions[$id] = @{ Version = $version; Extra = $extraAttrs } | |
| } | |
| # Track condition-specific versions | |
| if ($condition) { | |
| if (-not $conditionalPackages.ContainsKey($condition)) { | |
| $conditionalPackages[$condition] = @{} | |
| } | |
| if ($conditionalPackages[$condition].ContainsKey($id)) { | |
| if ((Compare-NuGetVersion $version $conditionalPackages[$condition][$id].Version) -gt 0) { | |
| $conditionalPackages[$condition][$id] = @{ Version = $version; Extra = $extraAttrs } | |
| } | |
| } else { | |
| $conditionalPackages[$condition][$id] = @{ Version = $version; Extra = $extraAttrs } | |
| } | |
| } | |
| $pkg.RemoveAttribute("Version") | |
| } | |
| # Remove PrivateAssets/IncludeAssets if not preserving | |
| if (-not $PreserveAssets) { | |
| if ($pkg.PrivateAssets) { $pkg.RemoveChild($pkg.SelectSingleNode("PrivateAssets")) | Out-Null } | |
| if ($pkg.IncludeAssets) { $pkg.RemoveChild($pkg.SelectSingleNode("IncludeAssets")) | Out-Null } | |
| } | |
| } | |
| } | |
| # Collect unique PackageReferences | |
| $uniqueIncludes = [System.Collections.Generic.HashSet[string]]::new() | |
| foreach ($group in $xml.Project.ItemGroup) { | |
| foreach ($pkg in $group.PackageReference) { | |
| $uniqueIncludes.Add($pkg.Include) | Out-Null | |
| } | |
| } | |
| # Remove all ItemGroups with PackageReferences | |
| foreach ($group in $xml.Project.ItemGroup | Where-Object { $_.PackageReference }) { | |
| $xml.Project.RemoveChild($group) | Out-Null | |
| } | |
| # Add one clean unified ItemGroup. Was tricky. | |
| $newItemGroup = $xml.CreateElement("ItemGroup") | |
| if ($PreserveAssets) { | |
| # Re-add original PackageReference nodes (without Version) | |
| foreach ($itemGroup in $itemGroups) { | |
| foreach ($pkg in $itemGroup.PackageReference) { | |
| $pkgClone = $pkg.CloneNode($true) | |
| $pkgClone.RemoveAttribute("Version") | |
| $newItemGroup.AppendChild($pkgClone) | Out-Null | |
| } | |
| } | |
| } else { | |
| foreach ($id in $uniqueIncludes) { | |
| $pkgNode = $xml.CreateElement("PackageReference") | |
| $pkgNode.SetAttribute("Include", $id) | |
| $newItemGroup.AppendChild($pkgNode) | Out-Null | |
| } | |
| } | |
| $xml.Project.AppendChild($newItemGroup) | Out-Null | |
| $xml.Save($file.FullName) | |
| Write-Host "`t✔️ Updated $($file.FullName)" -ForegroundColor Cyan | |
| } | |
| Write-Host "`tGenerating '$OutputFile' ..." | |
| # Collect all package names that appear in conditional groups | |
| $allConditionalPackageNames = [System.Collections.Generic.HashSet[string]]::new() | |
| foreach ($cond in $conditionalPackages.Keys) { | |
| foreach ($pkg in $conditionalPackages[$cond].Keys) { | |
| $allConditionalPackageNames.Add($pkg) | Out-Null | |
| } | |
| } | |
| # Build Directory.Packages.props | |
| $propsXml = @() | |
| $propsXml += "<Project>" | |
| $propsXml += " <PropertyGroup>" | |
| $propsXml += " <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>" | |
| $propsXml += " <CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>" | |
| $propsXml += " </PropertyGroup>" | |
| # Unconditional ItemGroup — exclude packages that appear in any conditional ItemGroup | |
| $propsXml += " <ItemGroup>" | |
| foreach ($pkg in $packageVersions.Keys | Sort-Object) { | |
| if (-not $allConditionalPackageNames.Contains($pkg)) { | |
| $ver = $packageVersions[$pkg].Version | |
| $extra = $packageVersions[$pkg].Extra | |
| $attrStr = "" | |
| foreach ($key in $extra.Keys) { | |
| $attrStr += " $key=`"$($extra[$key])`"" | |
| } | |
| $propsXml += " <PackageVersion Include=`"$pkg`" Version=`"$ver`"$attrStr />" | |
| } | |
| } | |
| $propsXml += " </ItemGroup>" | |
| # Add conditional ItemGroups | |
| foreach ($condition in $conditionalPackages.Keys) { | |
| $propsXml += " <ItemGroup Condition=`"$condition`">" | |
| foreach ($pkg in $conditionalPackages[$condition].Keys | Sort-Object) { | |
| $ver = $conditionalPackages[$condition][$pkg].Version | |
| $extra = $conditionalPackages[$condition][$pkg].Extra | |
| $attrStr = "" | |
| foreach ($key in $extra.Keys) { | |
| $attrStr += " $key=`"$($extra[$key])`"" | |
| } | |
| $propsXml += " <PackageVersion Include=`"$pkg`" Version=`"$ver`"$attrStr />" | |
| } | |
| $propsXml += " </ItemGroup>" | |
| } | |
| $propsXml += "</Project>" | |
| Set-Content -Path (Join-Path $RootPath $OutputFile) -Value ($propsXml -join "`r`n") -Encoding UTF8 | |
| Write-Host "✅ Done! Central package versions written to '$OutputFile'" -ForegroundColor Green | |
| } | |
| Convert-ToDirectoryPackages -RootPath $RootPath -OutputFile $OutputFile -PreserveAssets $PreserveAssets |
Author
Author
It preserves the nested elements such as PrivateAssets and IncludeAssets, but CPM would enforce it with:
<PackageVersion Include="coverlet.msbuild" Version="6.0.4" PrivateAssets="All" IncludeAssets="runtime; build; native; contentfiles; analyzers; buildtransitive" />
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
TODO: Make sure to preserve any nested elements: