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