Skip to content

Instantly share code, notes, and snippets.

@Wind010
Last active November 7, 2025 21:06
Show Gist options
  • Select an option

  • Save Wind010/1a5e5bff107a2a47f93e0cf46cc95e84 to your computer and use it in GitHub Desktop.

Select an option

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.
# 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
@Wind010
Copy link
Author

Wind010 commented Nov 5, 2025

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