NuGet, Dependency Management & A Single Point of Package Truth

NuGet, Dependency Management & A Single Point of Package Truth

For the last few years, I have been doing a lot of contracting on enterprise .NET systems. Microsoft's focus on open source has led to a lot of adoption in my local area. I am seeing things like Bootstrap, Angular & AutoMapper getting used more and more. NuGet is slowly pushing it's way in and delivering value for sure.

Enterprise codebases can be large sprawling masses of deeply nested directories spanning web, service, biztalk, SSIS and one off utility apps. Managing external dependencies can be a real bear to handle and keep clean.

One thing that is not so obvious when you start using NuGet to pull in packages is by default NuGet will create a packages folder next to the solution file containing the package reference. In an Enterprise codebase this could lead to multiple package directories littering your repository.

It is not uncommon to pull source on a project and see something like this:

find all packages directories

That is 7 packages directories scattered all over the place. Not only is this messy, but it could lead to a situation where you are not consistently using the same version of a dependency throughout the codebase. Totally possible to have three different versions of log4net in use and assembly binding redirects everywhere.

If there was single packages directory for the entire codebase, it would be easy to spot multiple versions of a dependency. It would also be easy to see what all external dependencies are in use by a given project.

Since Nuget 3.4 the location of the packages directory has been configurable. All you need to do is drop a Nuget.config into your source folder that looks like this:

nuget.config

Now at first glance this may look silly, of course I want my NuGet packages to go into a packages folder. The magic here is in where you put the NuGet.config file.

Say I have a source folder that looks something like this:

nuget.config

Where my root folder foo has two children bar and baz and baz has a child folder blep. Each of these folders has a solution in them. By default each of the folders would have a packages directory containing NuGet dependencies for that folder.

If I drop my NuGet.config file in the root folder foo, it will override the packages directory of all the solution files contained in folders below it. So all the dependencies for foo, bar, baz & blep.. would all be redirected to foo/packages.

Why do this? In my mind, cleanliness and discoverability are primary reasons. Having all external references coming from a common location makes it easy to see what dependencies are across the entire project. It also makes it easy to see if there are multiple versions of dependency in use. If you look in the packages directory and see 3 versions of log4net, you know some solution is out of date an needs to be updated.

That sounds awesome right? Well don't run right out and do this right now. If you were just to commit a Nuget.config file to the root of your repository there is a very good chance that your build will fail.

When you add a NuGet reference, the reference in your csproj file is added with a hint path. It looks something like this:

nuget.config

That hint path is now in conflict with the Nuget.config. We would have to fix these paths. The simplest way is to remove and re-add all NuGet dependencies everywhere. That is a extraordinarily time consuming manual process. And I am a lazy developer. So, I scripted it with PowerShell.

What I needed was a script that determines the location of my packages directory based on the Nuget.config file and update all hint paths in my csproj references.

Finding the Nuget.config and reading the packages directory location was fairly easy. As was getting a list of all csproj files recursively.

function Get-NuGetConfig 
{
    Get-ChildItem . NuGet.config -Recurse | Select-Object -First 1
}

function Get-PackagesLocation
{
    $configFile = Get-NuGetConfig
    $configPath = Resolve-Path $configFile | Split-Path -Parent 
    [xml]$config = Get-Content $configFile
    if($config -and $config.configuration -and $config.configuration.config)
    {
        $path = $config.configuration.config.add `
            | ? { $_.key -eq "repositoryPath" } `
            | Select-Object -ExpandProperty value
  
        [io.path]::Combine($configPath, $path)
    }
}

function Get-ProjectFiles
{
    Get-ChildItem -Recurse . *.csproj | Select -ExpandProperty FullName
}

It is then just a matter of checking all the references in each file and updating any hint paths based on a calculated relative path.

function Update-PacakgeRefereces
{
    param(
    [string]$packagesLocation,
    [parameter(ValueFromPipeline)]$projectFile
    )
  
  Process {
     Write-Host "Scanning: $projectFile"
     $relativePath = Get-RelativePath $projectFile $packagesLocation 
     [xml]$file = Get-Content $projectFile
     $edited = $false
     $file.SelectNodes("//*") `
        | ? { $_.Name -eq "Reference" -and $_.HintPath } `
        | ? { $_.HintPath.Contains("packages") } `
        | % {
            $_.HintPath = Combine-PackagePaths $relativePath $_.HintPath  
            Write-Host "Updated: $($_.HintPath)" -ForegroundColor Red 
            $edited = $true
        }
     if($edited)
     {
        Exec-Checkout $projectFile
        $file.save($projectFile)
     }
    }
}

The entire script can be found here.

"capitol" By Jim Heising is licensed under CC BY 2.0

Follow me on Mastodon!