From c7e0e03aa41995d2d33c887bc8f3cd1bb0dc0801 Mon Sep 17 00:00:00 2001 From: Bernhard Richter Date: Wed, 16 Sep 2026 13:02:02 +0200 Subject: [PATCH] Add --min-age option and bump to .NET 10 Adds a --min-age (-ma) option that holds back package versions published too recently. The value is a number with an optional suffix, 2d for days and 12h for hours, defaulting to days when no suffix is given. Project files can specify the same thing through the PackagesMinimumAge property, which applies when --min-age is not passed on the command line. When a minimum age is in effect we resolve versions through PackageMetadataResource so that we get the publish date for every version. Without it we stay on the cheaper FindPackageByIdResource path, since the metadata resource pulls full registration pages. Versions from feeds that do not report a publish date are never held back. Also bumps all projects to net10.0 and CI to the .NET 10 SDK. Closes #20 Co-Authored-By: Claude Opus 5 --- .github/workflows/dotnetcore.yml | 8 +- README.md | 36 ++++++++ global.json | 6 ++ src/Dotnet.Deps.Core/DependencyAnalyzer.cs | 60 ++++++++++++-- src/Dotnet.Deps.Core/Dotnet.Deps.Core.csproj | 4 +- src/Dotnet.Deps.Core/MinimumAge.cs | 65 +++++++++++++++ .../NuGet/ILatestVersionProvider.cs | 81 ++++++++++++------ src/Dotnet.Deps.Core/NuGet/PackageVersions.cs | 71 ++++++++++++++++ .../ProjectSystem/IProjectFile.cs | 8 ++ .../ProjectSystem/MsBuildProjectFile.cs | 10 ++- .../ProjectSystem/MsBuildProjectLoader.cs | 22 ++++- .../ProjectSystem/NuspecProjectFile.cs | 3 + .../ProjectSystem/ScriptProjectFile.cs | 3 + src/Dotnet.Deps.Tests/MinimumAgeTests.cs | 48 +++++++++++ src/Dotnet.Deps.Tests/MsBuildTests.cs | 82 +++++++++++++++++++ src/Dotnet.Deps.Tests/TestCase.cs | 17 ++++ .../dotnet.deps.tests.csproj | 2 +- src/Dotnet.Deps/App.cs | 13 +++ src/Dotnet.Deps/Dotnet.Deps.csproj | 4 +- 19 files changed, 499 insertions(+), 44 deletions(-) create mode 100644 global.json create mode 100644 src/Dotnet.Deps.Core/MinimumAge.cs create mode 100644 src/Dotnet.Deps.Core/NuGet/PackageVersions.cs create mode 100644 src/Dotnet.Deps.Tests/MinimumAgeTests.cs diff --git a/.github/workflows/dotnetcore.yml b/.github/workflows/dotnetcore.yml index a302d8f..de0857f 100644 --- a/.github/workflows/dotnetcore.yml +++ b/.github/workflows/dotnetcore.yml @@ -7,11 +7,11 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 - - name: Install .Net Core - uses: actions/setup-dotnet@v2.0.0 + - uses: actions/checkout@v4 + - name: Install .Net + uses: actions/setup-dotnet@v4 with: - dotnet-version: 8.0.405 + dotnet-version: 10.0.x - name: Install dotnet-script run: dotnet tool install dotnet-script --tool-path dotnet-script-tool diff --git a/README.md b/README.md index 7b9b32f..f51c6cb 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,42 @@ deps --filter McMaster > The filter is applied as an regular expression +### Minimum package age + +Brand new package versions are sometimes best left to simmer for a little while. The `--min-age` (`-ma`) option makes `dotnet-deps` ignore versions that were published too recently. + +```shell +deps --min-age 2d +``` + +The value is a number followed by an optional suffix. + +| Value | Meaning | +| ----- | ------- | +| `2d` | Ignore versions published less than 2 days ago | +| `12h` | Ignore versions published less than 12 hours ago | +| `2` | Same as `2d`. Without a suffix we default to days | + +When a newer version is held back, the version we *would* have picked is reported like this. + +```shell +LightInject 7.0.1 7.0.1 (nuget.org) 🍺 (holding back 7.1.0 ⏳) +``` + +The minimum age can also be specified in the project file using the `PackagesMinimumAge` property. + +```xml + + 2d + +``` + +The `--min-age` option takes precedence over `PackagesMinimumAge` when both are present. + +> `PackagesMinimumAge` must be declared in the same file as the package references. Just like for `` nodes, `dotnet-deps` does not evaluate MSBuild and will not pick up the property from an imported file such as `Directory.Build.props`. + +> Feeds that do not report a publish date for a version (some local and private feeds) will never have that version held back. + ### Locked dependencies If we should want to "lock" a dependency to a specific version, we can do that by adding the `Locked` attribute as shown here. diff --git a/global.json b/global.json new file mode 100644 index 0000000..512142d --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.100", + "rollForward": "latestFeature" + } +} diff --git a/src/Dotnet.Deps.Core/DependencyAnalyzer.cs b/src/Dotnet.Deps.Core/DependencyAnalyzer.cs index 213985f..bb47833 100644 --- a/src/Dotnet.Deps.Core/DependencyAnalyzer.cs +++ b/src/Dotnet.Deps.Core/DependencyAnalyzer.cs @@ -20,6 +20,8 @@ public class DependencyAnalyzer private bool allowPreReleasePackages; + private TimeSpan? minimumAge; + private AppConsole console = new AppConsole(TextWriter.Null, TextWriter.Null); public DependencyAnalyzer WithRootFolder(string rootFolder) @@ -56,6 +58,16 @@ public DependencyAnalyzer WithPreReleaseOption(bool allowPreReleasePackages) return this; } + /// + /// Specifies the minimum age a package version must have before it is considered for an update. + /// Takes precedence over the PackagesMinimumAge property in the project file. + /// + public DependencyAnalyzer WithMinimumAge(TimeSpan? minimumAge) + { + this.minimumAge = minimumAge; + return this; + } + public async Task Execute() { var projectCollectionLoader = new ProjectCollectionLoader(console); @@ -70,11 +82,20 @@ public async Task Execute() console.WriteNormal($"Found {allPackages.Length} package references across {projectCollection.ProjectFiles.Length} project(s)"); - var latestVersions = await latestVersionProvider.GetLatestVersions(allPackageNames, rootFolder, allowPreReleasePackages); + var minimumAgeIsInEffect = minimumAge.HasValue || projectCollection.ProjectFiles.Any(pf => pf.MinimumPackageAge.HasValue); + var packageVersions = await latestVersionProvider.GetPackageVersions(allPackageNames, rootFolder, allowPreReleasePackages, minimumAgeIsInEffect); + var utcNow = DateTimeOffset.UtcNow; foreach (var projectFile in projectCollection.ProjectFiles) { console.WriteHeader(projectFile.Path); + + var effectiveMinimumAge = minimumAge ?? projectFile.MinimumPackageAge; + if (effectiveMinimumAge.HasValue && effectiveMinimumAge.Value > TimeSpan.Zero) + { + console.WriteNormal($"Ignoring package versions published less than {MinimumAge.Format(effectiveMinimumAge.Value)} ago ⏳"); + } + foreach (var packageReference in projectFile.PackageReferences) { if (!Regex.IsMatch(packageReference.Name, filter)) @@ -95,32 +116,43 @@ public async Task Execute() if (FloatRange.TryParse(packageVersion, out var floatRange)) { - if (latestVersions.TryGetValue(packageReference.Name, out var latestVersion)) + if (packageVersions.TryGetValue(packageReference.Name, out var availableVersions)) { + var latestVersion = availableVersions.GetLatestVersion(effectiveMinimumAge, utcNow); + if (!latestVersion.IsValid) { - console.WriteError($"Unable to find package {packageReference.Name} ({packageReference.Version})"); + if (availableVersions.HasVersions) + { + console.WriteHighlighted($"{packageReference.Name} {packageReference.Version} - no version is older than {MinimumAge.Format(effectiveMinimumAge.Value)} ⏳"); + } + else + { + console.WriteError($"Unable to find package {packageReference.Name} ({packageReference.Version})"); + } continue; } + var heldBack = GetHeldBackSuffix(availableVersions, latestVersion, effectiveMinimumAge, utcNow); + if (!IsLatestVersion(floatRange, latestVersion.NugetVersion)) { if (updateDependencies) { - console.WriteHighlighted($"{packageReference.Name} {packageReference.Version} => {latestVersion.NugetVersion} ({latestVersion.Feed}) UPDATED 🍺"); + console.WriteHighlighted($"{packageReference.Name} {packageReference.Version} => {latestVersion.NugetVersion} ({latestVersion.Feed}) UPDATED 🍺{heldBack}"); packageReference.Update(latestVersion.NugetVersion.ToString()); results.Add(new Result(floatRange.MinVersion.ToString(), latestVersion.NugetVersion.ToString(), true, latestVersion.Feed, projectFile.Path)); } else { results.Add(new Result(floatRange.MinVersion.ToString(), latestVersion.NugetVersion.ToString(), false, latestVersion.Feed, projectFile.Path)); - console.WriteHighlighted($"{packageReference.Name} {packageReference.Version} => {latestVersion.NugetVersion} ({latestVersion.Feed}) 😢"); + console.WriteHighlighted($"{packageReference.Name} {packageReference.Version} => {latestVersion.NugetVersion} ({latestVersion.Feed}) 😢{heldBack}"); } } else { results.Add(new Result(floatRange.MinVersion.ToString(), latestVersion.NugetVersion.ToString(), true, latestVersion.Feed, projectFile.Path)); - console.WriteSuccess($"{packageReference.Name} {packageReference.Version} {latestVersion.NugetVersion} ({latestVersion.Feed}) 🍺"); + console.WriteSuccess($"{packageReference.Name} {packageReference.Version} {latestVersion.NugetVersion} ({latestVersion.Feed}) 🍺{heldBack}"); } } } @@ -147,6 +179,22 @@ public async Task Execute() return results.ToArray(); } + private static string GetHeldBackSuffix(PackageVersions availableVersions, LatestVersion latestVersion, TimeSpan? effectiveMinimumAge, DateTimeOffset utcNow) + { + if (!effectiveMinimumAge.HasValue || effectiveMinimumAge.Value <= TimeSpan.Zero) + { + return string.Empty; + } + + var newestVersion = availableVersions.GetLatestVersion(null, utcNow); + if (newestVersion.IsValid && newestVersion.NugetVersion > latestVersion.NugetVersion) + { + return $" (holding back {newestVersion.NugetVersion} ⏳)"; + } + + return string.Empty; + } + private bool IsLatestVersion(FloatRange currentVersion, NuGetVersion latestVersion) { if (currentVersion.FloatBehavior == NuGetVersionFloatBehavior.None) diff --git a/src/Dotnet.Deps.Core/Dotnet.Deps.Core.csproj b/src/Dotnet.Deps.Core/Dotnet.Deps.Core.csproj index 5c4fa2b..9e4b341 100644 --- a/src/Dotnet.Deps.Core/Dotnet.Deps.Core.csproj +++ b/src/Dotnet.Deps.Core/Dotnet.Deps.Core.csproj @@ -1,7 +1,7 @@  - netstandard2.0 + net10.0 false dotnet;cli;csx;nuget;tool https://github.com/seesharper/dotnet-deps @@ -10,7 +10,7 @@ https://github.com/seesharper/dotnet-deps.git Bernhard Richter A simple library that can be used to analyze and update NuGet dependencies. - 2.1.2 + 2.2.0 latest diff --git a/src/Dotnet.Deps.Core/MinimumAge.cs b/src/Dotnet.Deps.Core/MinimumAge.cs new file mode 100644 index 0000000..47f5e60 --- /dev/null +++ b/src/Dotnet.Deps.Core/MinimumAge.cs @@ -0,0 +1,65 @@ +using System; +using System.Globalization; + +namespace Dotnet.Deps.Core +{ + /// + /// Parses and formats the minimum age a package version must have before it is considered for an update. + /// + public static class MinimumAge + { + /// + /// Tries to parse a minimum age such as "2d" (two days), "12h" (twelve hours) or "2" (two days). + /// + public static bool TryParse(string value, out TimeSpan minimumAge) + { + minimumAge = TimeSpan.Zero; + + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + var trimmedValue = value.Trim(); + var unit = 'd'; + var lastCharacter = trimmedValue[trimmedValue.Length - 1]; + if (!char.IsDigit(lastCharacter)) + { + unit = char.ToLowerInvariant(lastCharacter); + trimmedValue = trimmedValue.Substring(0, trimmedValue.Length - 1).Trim(); + } + + if (!double.TryParse(trimmedValue, NumberStyles.Float, CultureInfo.InvariantCulture, out var number) || number < 0) + { + return false; + } + + if (unit == 'd' && number <= TimeSpan.MaxValue.TotalDays) + { + minimumAge = TimeSpan.FromDays(number); + return true; + } + + if (unit == 'h' && number <= TimeSpan.MaxValue.TotalHours) + { + minimumAge = TimeSpan.FromHours(number); + return true; + } + + return false; + } + + /// + /// Formats a minimum age for display purposes. + /// + public static string Format(TimeSpan minimumAge) + { + if (minimumAge.TotalDays >= 1) + { + return $"{minimumAge.TotalDays.ToString("0.##", CultureInfo.InvariantCulture)} {(minimumAge.TotalDays == 1 ? "day" : "days")}"; + } + + return $"{minimumAge.TotalHours.ToString("0.##", CultureInfo.InvariantCulture)} {(minimumAge.TotalHours == 1 ? "hour" : "hours")}"; + } + } +} diff --git a/src/Dotnet.Deps.Core/NuGet/ILatestVersionProvider.cs b/src/Dotnet.Deps.Core/NuGet/ILatestVersionProvider.cs index 3cb3639..cfdad27 100644 --- a/src/Dotnet.Deps.Core/NuGet/ILatestVersionProvider.cs +++ b/src/Dotnet.Deps.Core/NuGet/ILatestVersionProvider.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; @@ -15,6 +16,15 @@ namespace Dotnet.Deps.Core.NuGet public interface ILatestVersionProvider { Task> GetLatestVersions(string[] packageNames, string rootFolder, bool preRelease); + + /// + /// Gets the candidate versions for the given packages. + /// + /// + /// When true, all versions are returned along with their publish date so that they can be filtered by age. + /// When false, only the latest version per feed is returned, which is considerably cheaper. + /// + Task> GetPackageVersions(string[] packageNames, string rootFolder, bool preRelease, bool includePublishedDates); } public class LatestVersionProvider : ILatestVersionProvider @@ -27,12 +37,19 @@ public LatestVersionProvider(AppConsole console) } public async Task> GetLatestVersions(string[] packageNames, string rootFolder, bool preRelease) + { + var packageVersions = await GetPackageVersions(packageNames, rootFolder, preRelease, false).ConfigureAwait(false); + var utcNow = DateTimeOffset.UtcNow; + return packageVersions.ToDictionary(pv => pv.Key, pv => pv.Value.GetLatestVersion(null, utcNow)); + } + + public async Task> GetPackageVersions(string[] packageNames, string rootFolder, bool preRelease, bool includePublishedDates) { console.WriteHighlighted($"Getting the latest package versions. Hang on....."); var sourceRepositories = GetSourceRepositories(rootFolder); - var result = new ConcurrentBag(); + var result = new ConcurrentBag(); int totalTicks = packageNames.Length; var options = new ProgressBarOptions @@ -43,12 +60,9 @@ public async Task> GetLatestVersions(string[] using (var progressBar = new ProgressBar(totalTicks, "Getting latest package versions", options)) { - await Task.WhenAll(packageNames.Select(name => GetLatestVersion(name, preRelease, sourceRepositories, result, progressBar))).ConfigureAwait(false); + await Task.WhenAll(packageNames.Select(name => GetPackageVersions(name, preRelease, includePublishedDates, sourceRepositories, result, progressBar))).ConfigureAwait(false); } - - - return result.ToDictionary(v => v.PackageName); } @@ -76,38 +90,51 @@ private static ISourceRepositoryProvider GetSourceRepositoryProvider(string root return new SourceRepositoryProvider(packageSourceProvider, Repository.Provider.GetCoreV3()); } - private async Task GetLatestVersion(string packageName, bool preRelease, SourceRepository[] repositories, ConcurrentBag result, ProgressBar progressBar) + private async Task GetPackageVersions(string packageName, bool preRelease, bool includePublishedDates, SourceRepository[] repositories, ConcurrentBag result, ProgressBar progressBar) { - List allLatestVersions = new List(); + List allVersions = new List(); foreach (var repository in repositories) { - var findResource = repository.GetResource(); - var allVersions = await findResource.GetAllVersionsAsync(packageName, new SourceCacheContext(), NullLogger.Instance, CancellationToken.None); - NuGetVersion latestVersionInRepository; - - if (preRelease) + if (includePublishedDates) { - latestVersionInRepository = allVersions.OrderBy(nv => nv).LastOrDefault(); + allVersions.AddRange(await GetVersionsWithPublishedDate(packageName, preRelease, repository).ConfigureAwait(false)); } else { - latestVersionInRepository = allVersions.Where(v => !v.IsPrerelease).OrderBy(nv => nv).LastOrDefault(); - } - - if (latestVersionInRepository != null) - { - allLatestVersions.Add(new LatestVersion(packageName, latestVersionInRepository, repository.ToString())); + var latestVersionInRepository = await GetLatestVersionInRepository(packageName, preRelease, repository).ConfigureAwait(false); + if (latestVersionInRepository != null) + { + allVersions.Add(new PackageVersion(latestVersionInRepository, null, repository.ToString())); + } } } - if (!allLatestVersions.Any()) - { - result.Add(new LatestVersion(packageName)); - } - else + + result.Add(new PackageVersions(packageName, allVersions)); + progressBar.Tick(packageName); + } + + private static async Task GetLatestVersionInRepository(string packageName, bool preRelease, SourceRepository repository) + { + var findResource = repository.GetResource(); + var allVersions = await findResource.GetAllVersionsAsync(packageName, new SourceCacheContext(), NullLogger.Instance, CancellationToken.None).ConfigureAwait(false); + + if (preRelease) { - result.Add(allLatestVersions.OrderBy(lv => lv.NugetVersion).Last()); + return allVersions.OrderBy(nv => nv).LastOrDefault(); } - progressBar.Tick(packageName); + + return allVersions.Where(v => !v.IsPrerelease).OrderBy(nv => nv).LastOrDefault(); + } + + private static async Task> GetVersionsWithPublishedDate(string packageName, bool preRelease, SourceRepository repository) + { + var metadataResource = await repository.GetResourceAsync().ConfigureAwait(false); + var metadata = await metadataResource.GetMetadataAsync(packageName, preRelease, false, new SourceCacheContext(), NullLogger.Instance, CancellationToken.None).ConfigureAwait(false); + + return metadata + .Where(m => preRelease || !m.Identity.Version.IsPrerelease) + .Select(m => new PackageVersion(m.Identity.Version, m.Published, repository.ToString())) + .ToArray(); } } -} \ No newline at end of file +} diff --git a/src/Dotnet.Deps.Core/NuGet/PackageVersions.cs b/src/Dotnet.Deps.Core/NuGet/PackageVersions.cs new file mode 100644 index 0000000..88f0c35 --- /dev/null +++ b/src/Dotnet.Deps.Core/NuGet/PackageVersions.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NuGet.Versioning; + +namespace Dotnet.Deps.Core.NuGet +{ + /// + /// Represents a single version of a NuGet package found in a given feed. + /// + public class PackageVersion + { + public PackageVersion(NuGetVersion version, DateTimeOffset? published, string feed) + { + Version = version; + Published = published; + Feed = feed; + } + + public NuGetVersion Version { get; } + + /// + /// Gets the point in time when this version was published or null if the feed does not report it. + /// + public DateTimeOffset? Published { get; } + + public string Feed { get; } + } + + /// + /// Represents the candidate versions of a NuGet package across all configured feeds. + /// + public class PackageVersions + { + private readonly PackageVersion[] versions; + + public PackageVersions(string packageName, IEnumerable versions) + { + PackageName = packageName; + this.versions = versions.OrderBy(v => v.Version).ToArray(); + } + + public string PackageName { get; } + + public IReadOnlyList Versions { get => versions; } + + public bool HasVersions { get => versions.Length > 0; } + + /// + /// Gets the latest version that is at least old. + /// Versions for which the feed does not report a publish date are never held back. + /// + public LatestVersion GetLatestVersion(TimeSpan? minimumAge, DateTimeOffset utcNow) + { + IEnumerable candidates = versions; + if (minimumAge.HasValue && minimumAge.Value > TimeSpan.Zero) + { + var cutOff = utcNow - minimumAge.Value; + candidates = candidates.Where(v => !v.Published.HasValue || v.Published.Value <= cutOff); + } + + var latestVersion = candidates.LastOrDefault(); + if (latestVersion == null) + { + return new LatestVersion(PackageName); + } + + return new LatestVersion(PackageName, latestVersion.Version, latestVersion.Feed); + } + } +} diff --git a/src/Dotnet.Deps.Core/ProjectSystem/IProjectFile.cs b/src/Dotnet.Deps.Core/ProjectSystem/IProjectFile.cs index 7bbc063..2544757 100644 --- a/src/Dotnet.Deps.Core/ProjectSystem/IProjectFile.cs +++ b/src/Dotnet.Deps.Core/ProjectSystem/IProjectFile.cs @@ -1,3 +1,5 @@ +using System; + namespace Dotnet.Deps.Core.ProjectSystem { /// @@ -11,6 +13,12 @@ public interface IProjectFile where TPackageReference : N /// TPackageReference[] PackageReferences { get; } + /// + /// Gets the minimum age a package version must have before it is considered for an update + /// or null if the project file does not specify it. + /// + TimeSpan? MinimumPackageAge { get; } + /// /// Saves the project file. /// diff --git a/src/Dotnet.Deps.Core/ProjectSystem/MsBuildProjectFile.cs b/src/Dotnet.Deps.Core/ProjectSystem/MsBuildProjectFile.cs index 111e5b4..116a9aa 100644 --- a/src/Dotnet.Deps.Core/ProjectSystem/MsBuildProjectFile.cs +++ b/src/Dotnet.Deps.Core/ProjectSystem/MsBuildProjectFile.cs @@ -1,3 +1,4 @@ +using System; using System.Xml.Linq; namespace Dotnet.Deps.Core.ProjectSystem @@ -10,14 +11,21 @@ public class MsBuildProjectFile : IProjectFile private readonly XDocument msBuildProjectFile; - public MsBuildProjectFile(XDocument msBuildProjectFile, string path) + public MsBuildProjectFile(XDocument msBuildProjectFile, string path) : this(msBuildProjectFile, path, null) + { + } + + public MsBuildProjectFile(XDocument msBuildProjectFile, string path, TimeSpan? minimumPackageAge) { this.msBuildProjectFile = msBuildProjectFile; Path = path; + MinimumPackageAge = minimumPackageAge; } public MsBuildPackageReference[] PackageReferences { get; set; } + public TimeSpan? MinimumPackageAge { get; } + public string Path { get; } public void Save() diff --git a/src/Dotnet.Deps.Core/ProjectSystem/MsBuildProjectLoader.cs b/src/Dotnet.Deps.Core/ProjectSystem/MsBuildProjectLoader.cs index 0e42d90..ea919f1 100644 --- a/src/Dotnet.Deps.Core/ProjectSystem/MsBuildProjectLoader.cs +++ b/src/Dotnet.Deps.Core/ProjectSystem/MsBuildProjectLoader.cs @@ -1,4 +1,6 @@ +using System; using System.Collections.Generic; +using System.Linq; using System.Xml.Linq; using NuGet.Versioning; @@ -19,7 +21,7 @@ public IProjectFile Load(string path) { var projectFile = XDocument.Load(path); var nameSpace = projectFile.Root.Name.Namespace; - var msBuildProjectFile = new MsBuildProjectFile(projectFile, path); + var msBuildProjectFile = new MsBuildProjectFile(projectFile, path, GetMinimumPackageAge(projectFile, nameSpace, path)); var packageReferenceElements = projectFile.Descendants(nameSpace + "PackageReference"); var packageReferences = new List(); foreach (var packageReferenceElement in packageReferenceElements) @@ -63,6 +65,24 @@ public IProjectFile Load(string path) return msBuildProjectFile; } + + private TimeSpan? GetMinimumPackageAge(XDocument projectFile, XNamespace nameSpace, string path) + { + var minimumPackageAgeElement = projectFile.Descendants(nameSpace + "PackagesMinimumAge").LastOrDefault(); + if (minimumPackageAgeElement == null) + { + return null; + } + + var value = minimumPackageAgeElement.Value; + if (MinimumAge.TryParse(value, out var minimumPackageAge)) + { + return minimumPackageAge; + } + + console.WriteError($"Warning: The project file '{path}' has an invalid PackagesMinimumAge value '{value}'"); + return null; + } } } \ No newline at end of file diff --git a/src/Dotnet.Deps.Core/ProjectSystem/NuspecProjectFile.cs b/src/Dotnet.Deps.Core/ProjectSystem/NuspecProjectFile.cs index f0679f7..426c047 100644 --- a/src/Dotnet.Deps.Core/ProjectSystem/NuspecProjectFile.cs +++ b/src/Dotnet.Deps.Core/ProjectSystem/NuspecProjectFile.cs @@ -1,3 +1,4 @@ +using System; using System.Xml.Linq; namespace Dotnet.Deps.Core.ProjectSystem @@ -18,6 +19,8 @@ public NuspecProjectFile(XDocument msBuildProjectFile, string path) public NuspecPackageReference[] PackageReferences { get; set; } + public TimeSpan? MinimumPackageAge { get => null; } + public string Path { get; } public void Save() diff --git a/src/Dotnet.Deps.Core/ProjectSystem/ScriptProjectFile.cs b/src/Dotnet.Deps.Core/ProjectSystem/ScriptProjectFile.cs index 2d0ec69..aca8f3c 100644 --- a/src/Dotnet.Deps.Core/ProjectSystem/ScriptProjectFile.cs +++ b/src/Dotnet.Deps.Core/ProjectSystem/ScriptProjectFile.cs @@ -1,3 +1,4 @@ +using System; using System.IO; using Dotnet.Deps.Core.ProjectSystem; @@ -15,6 +16,8 @@ public ScriptProjectFile(ScriptFileContent content, string path) public ScriptPackageReference[] PackageReferences { get; set; } + public TimeSpan? MinimumPackageAge { get => null; } + public string Path { get; } public void Save() diff --git a/src/Dotnet.Deps.Tests/MinimumAgeTests.cs b/src/Dotnet.Deps.Tests/MinimumAgeTests.cs new file mode 100644 index 0000000..004859d --- /dev/null +++ b/src/Dotnet.Deps.Tests/MinimumAgeTests.cs @@ -0,0 +1,48 @@ +using System; +using Dotnet.Deps.Core; +using FluentAssertions; +using Xunit; + +namespace Dotnet.Deps.Tests +{ + public class MinimumAgeTests + { + [Theory] + [InlineData("2d", 48)] + [InlineData("2D", 48)] + [InlineData("12h", 12)] + [InlineData("12H", 12)] + [InlineData("2", 48)] + [InlineData(" 2 d ", 48)] + [InlineData("0", 0)] + [InlineData("0.5d", 12)] + public void ShouldParseMinimumAge(string value, double expectedHours) + { + MinimumAge.TryParse(value, out var minimumAge).Should().BeTrue(); + minimumAge.Should().Be(TimeSpan.FromHours(expectedHours)); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + [InlineData("rubbish")] + [InlineData("2w")] + [InlineData("-2d")] + [InlineData("d")] + public void ShouldNotParseInvalidMinimumAge(string value) + { + MinimumAge.TryParse(value, out _).Should().BeFalse(); + } + + [Theory] + [InlineData(48, "2 days")] + [InlineData(24, "1 day")] + [InlineData(12, "12 hours")] + [InlineData(1, "1 hour")] + [InlineData(36, "1.5 days")] + public void ShouldFormatMinimumAge(double hours, string expected) + { + MinimumAge.Format(TimeSpan.FromHours(hours)).Should().Be(expected); + } + } +} diff --git a/src/Dotnet.Deps.Tests/MsBuildTests.cs b/src/Dotnet.Deps.Tests/MsBuildTests.cs index ae3b9e6..4a43169 100644 --- a/src/Dotnet.Deps.Tests/MsBuildTests.cs +++ b/src/Dotnet.Deps.Tests/MsBuildTests.cs @@ -103,5 +103,87 @@ public void ShouldIgnoreLockedDependency() result.StandardOut.Should().NotContain("LightInject 5.1.0 =>"); result.StandardOut.Should().Contain("LightInject 5.1.0 LOCKED 🔒"); } + + [Fact] + public void ShouldIgnoreVersionsNewerThanMinimumAge() + { + var result = new MsBuildTestCase() + .AddPackage("LightInject", "5.1.0") + .Execute("--min-age", "100000"); + result.StandardOut.Should().Contain("Ignoring package versions published less than 100000 days ago ⏳"); + result.StandardOut.Should().Contain("LightInject 5.1.0 - no version is older than 100000 days ⏳"); + result.ExitCode.Should().Be(0); + } + + [Fact] + public void ShouldIgnoreVersionsNewerThanMinimumAgeGivenInHours() + { + var result = new MsBuildTestCase() + .AddPackage("LightInject", "5.1.0") + .Execute("--min-age", "12h"); + result.StandardOut.Should().Contain("Ignoring package versions published less than 12 hours ago ⏳"); + } + + [Fact] + public void ShouldSupportShortFormMinimumAgeOption() + { + var result = new MsBuildTestCase() + .AddPackage("LightInject", "5.1.0") + .Execute("-ma", "100000d"); + result.StandardOut.Should().Contain("LightInject 5.1.0 - no version is older than 100000 days ⏳"); + } + + [Fact] + public void ShouldListOutdatedDependencyOlderThanMinimumAge() + { + var result = new MsBuildTestCase() + .AddPackage("LightInject", "5.1.0") + .Execute("--min-age", "1h"); + result.StandardOut.Should().Contain("LightInject 5.1.0 =>"); + result.ExitCode.Should().Be(0xbad); + } + + [Fact] + public void ShouldUseMinimumAgeFromProjectFile() + { + var result = new MsBuildTestCase() + .AddPackage("LightInject", "5.1.0") + .WithMinimumPackageAge("100000d") + .Execute(); + result.StandardOut.Should().Contain("LightInject 5.1.0 - no version is older than 100000 days ⏳"); + result.ExitCode.Should().Be(0); + } + + [Fact] + public void ShouldPreferMinimumAgeOptionOverProjectFile() + { + var result = new MsBuildTestCase() + .AddPackage("LightInject", "5.1.0") + .WithMinimumPackageAge("100000d") + .Execute("--min-age", "1h"); + result.StandardOut.Should().Contain("LightInject 5.1.0 =>"); + result.ExitCode.Should().Be(0xbad); + } + + [Fact] + public void ShouldHandleInvalidMinimumAgeOption() + { + var result = new MsBuildTestCase() + .AddPackage("LightInject", "5.1.0") + .Execute("--min-age", "rubbish"); + result.StandardOut.Should().Contain("Invalid value 'rubbish' for the --min-age option"); + result.ExitCode.Should().Be(1); + } + + [Fact] + public void ShouldHandleInvalidMinimumAgeInProjectFile() + { + var result = new MsBuildTestCase() + .AddPackage("LightInject", "5.1.0") + .WithMinimumPackageAge("rubbish") + .Execute(); + result.StandardOut.Should().Contain("invalid PackagesMinimumAge value 'rubbish'"); + result.StandardOut.Should().Contain("LightInject 5.1.0 =>"); + } } } diff --git a/src/Dotnet.Deps.Tests/TestCase.cs b/src/Dotnet.Deps.Tests/TestCase.cs index 231ad7e..ed7a459 100644 --- a/src/Dotnet.Deps.Tests/TestCase.cs +++ b/src/Dotnet.Deps.Tests/TestCase.cs @@ -39,6 +39,8 @@ public class MsBuildTestCase protected string filter; + protected string minimumPackageAge; + public MsBuildTestCase AddPackage(string name, string version = "", bool pinned = false) { packageReferences.Add((name, version, pinned)); @@ -51,9 +53,24 @@ public MsBuildTestCase WithFilter(string filter) return this; } + /// + /// Adds the PackagesMinimumAge property to the project file. + /// + public MsBuildTestCase WithMinimumPackageAge(string minimumPackageAge) + { + this.minimumPackageAge = minimumPackageAge; + return this; + } + protected string CreateProjectFile() { XDocument projectFile = XDocument.Parse(msBuildProjectFile); + + if (!string.IsNullOrEmpty(minimumPackageAge)) + { + projectFile.Descendants("PropertyGroup").Single().Add(new XElement("PackagesMinimumAge", minimumPackageAge)); + } + var itemGroupElement = projectFile.Descendants("ItemGroup").Single(); foreach (var packageReference in packageReferences) { diff --git a/src/Dotnet.Deps.Tests/dotnet.deps.tests.csproj b/src/Dotnet.Deps.Tests/dotnet.deps.tests.csproj index 3451641..8682b61 100644 --- a/src/Dotnet.Deps.Tests/dotnet.deps.tests.csproj +++ b/src/Dotnet.Deps.Tests/dotnet.deps.tests.csproj @@ -1,7 +1,7 @@  - net8.0 + net10.0 false false diff --git a/src/Dotnet.Deps/App.cs b/src/Dotnet.Deps/App.cs index d18e5df..2409341 100644 --- a/src/Dotnet.Deps/App.cs +++ b/src/Dotnet.Deps/App.cs @@ -25,16 +25,29 @@ public int Execute(params string[] args) var versionOption = app.VersionOption("-v | --version", GetVersion()); var preReleaseOption = app.Option("-p ||--pre", "Allow prerelease packages", CommandOptionType.NoValue); var updateOption = app.Option("-u ||--update", "Update packages to their latest versions", CommandOptionType.NoValue); + var minAgeOption = app.Option("-ma | --min-age", "Minimum age of a package version before it is considered for an update. E.g. 2d (days) or 12h (hours). Defaults to days when no suffix is given.", CommandOptionType.SingleValue); var helpOption = app.HelpOption("-h | --help"); app.OnExecuteAsync(async cancellationToken => { + TimeSpan? minimumAge = null; + if (minAgeOption.HasValue()) + { + if (!MinimumAge.TryParse(minAgeOption.Value(), out var parsedMinimumAge)) + { + console.WriteError($"Invalid value '{minAgeOption.Value()}' for the --min-age option. Expected for instance 2d (days), 12h (hours) or 2 (days)."); + return 1; + } + minimumAge = parsedMinimumAge; + } + var results = await new DependencyAnalyzer() .WithRootFolder(cwd.HasValue() ? cwd.Value() : Directory.GetCurrentDirectory()) .WithConsoleOutput(console) .WithFilter(filterOption.Value()) .WithPreReleaseOption(preReleaseOption.HasValue()) .WithUpdateOption(updateOption.HasValue()) + .WithMinimumAge(minimumAge) .Execute(); return results.Any(r => !r.IsLatestVersion) ? 0xbad : 0; }); diff --git a/src/Dotnet.Deps/Dotnet.Deps.csproj b/src/Dotnet.Deps/Dotnet.Deps.csproj index b5e63e9..e216a88 100644 --- a/src/Dotnet.Deps/Dotnet.Deps.csproj +++ b/src/Dotnet.Deps/Dotnet.Deps.csproj @@ -1,7 +1,7 @@  - net8.0 + net10.0 Exe Bernhard Richter A simple command line (.Net Global tool) to inspect and update dependencies. @@ -15,7 +15,7 @@ MIT git https://github.com/seesharper/dotnet-deps.git - 3.1.0 + 3.2.0