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