diff --git a/claude.md b/claude.md index 79125897..d601cc1c 100644 --- a/claude.md +++ b/claude.md @@ -12,6 +12,59 @@ PackageUpdate is a .NET global tool that updates NuGet packages for all solution - Uses C# preview language features (`LangVersion>preview`) - Central Package Management (CPM) is required for all target solutions +## Coding Conventions + +### Lambda Expressions + +Always use underscore `_` for single-parameter lambda expressions instead of named parameters: + +```csharp +// ✅ Correct +packages.Where(_ => _.Id == "MyPackage") +packages.FirstOrDefault(_ => _.Version == "1.0.0") +packages.OrderByDescending(_ => _) +elements.Any(_ => _.IsEnabled) + +// ❌ Incorrect - don't use named parameters +packages.Where(p => p.Id == "MyPackage") +packages.FirstOrDefault(pkg => pkg.Version == "1.0.0") +elements.Any(e => e.IsEnabled) +``` + +This applies even when the parameter is used multiple times in the expression: + +```csharp +// ✅ Correct +xml.Descendants("PackageVersion") + .FirstOrDefault(_ => + string.Equals( + _.Attribute("Include")?.Value, + packageName, + StringComparison.OrdinalIgnoreCase)) + +// ❌ Incorrect +xml.Descendants("PackageVersion") + .FirstOrDefault(e => + string.Equals( + e.Attribute("Include")?.Value, + packageName, + StringComparison.OrdinalIgnoreCase)) +``` + +**Exception:** Use descriptive parameter names when creating complex anonymous types or when the lambda body is long and clarity would benefit from a meaningful name: + +```csharp +// Named parameter acceptable for complex Select projections +var packageVersions = xml.Descendants("PackageVersion") + .Select(element => new + { + Element = element, + Package = element.Attribute("Include")?.Value, + CurrentVersion = element.Attribute("Version")?.Value, + Pinned = element.Attribute("Pinned")?.Value == "true" + }) +``` + ## Build and Test Commands ```bash @@ -46,10 +99,11 @@ packageupdate --build ### Key Components -- **Updater.cs**: Core update logic +- **Updater.cs**: Core update and migration logic - Parses `Directory.Packages.props` XML - Respects `Pinned="true"` attribute to skip packages - Queries NuGet sources for latest versions via NuGet.Protocol API + - Detects deprecated packages and auto-migrates to alternatives when current version is deprecated - Preserves file formatting (newlines, indentation, trailing newlines) - Only considers stable versions when current version is stable - Only considers pre-release versions when current version is pre-release @@ -93,6 +147,50 @@ The tool only works with CPM. Each solution must have a `Directory.Packages.prop Packages with `Pinned="true"` attribute are never updated, even when explicitly targeted via `--package` flag. +### Package Migration + +The tool automatically detects and migrates deprecated packages when an alternative is available. + +#### How It Works + +When updating packages, PackageUpdate checks if the **current version** of a package is marked as deprecated in NuGet. If the package has an alternative specified and that alternative is available in configured NuGet sources, the tool will automatically migrate: + +1. Replaces the `Include` attribute with the alternative package name +2. Sets the `Version` to the latest version of the alternative (or the minimum version from the range if specified) +3. Logs the migration with deprecation reason + +#### Migration Examples + +```xml + + + + + +``` + +#### Migration Behavior + +- **Pinned packages**: Never migrated (Pinned="true" is respected) +- **No alternative available**: Package version updated normally, warning logged +- **Alternative not found**: Package version updated normally, warning logged +- **Alternative already exists**: Migration skipped, warning logged, both packages remain +- **--package flag**: Migrations still occur for specified deprecated packages +- **Current version check**: Only migrates if the **current** version is deprecated, not if only newer versions are deprecated + +#### Migration Logging + +Successful migration: +``` +Migrated WindowsAzure.Storage -> Azure.Storage.Common (Version: 12.26.0) [Deprecated: Legacy] +``` + +Deprecation warnings (when no migration possible): +``` +Package WindowsAzure.Storage is deprecated but has no alternative. Reasons: Legacy +Package WindowsAzure.Storage is deprecated with alternative Azure.Storage.Common, but alternative already exists +``` + ### Version Selection Logic - Uses `FindPackageByIdResource` to query all versions efficiently diff --git a/readme.md b/readme.md index 34237796..daf548e4 100644 --- a/readme.md +++ b/readme.md @@ -317,6 +317,199 @@ The next time you run the updater, it will update to the latest version. - Comments and formatting around pinned packages are preserved during updates +## Automatic Package Migration + + +### Overview + +PackageUpdate automatically detects and migrates deprecated NuGet packages to their recommended alternatives. When a package is marked as deprecated on NuGet.org with an alternative package specified, the tool will automatically replace it during updates. + + +### How It Works + +When updating packages, PackageUpdate: + +1. Checks if the **current version** of each package is marked as deprecated +2. If an alternative package is specified in the deprecation metadata: + - Verifies the alternative package exists in configured NuGet sources + - Checks that the alternative doesn't already exist in `Directory.Packages.props` + - Replaces the package reference with the alternative + - Sets the version to the latest available version of the alternative +3. Logs the migration with the deprecation reason + + +### Example Migration + +**Before:** + +```xml + + + + + + +``` + +**After running `packageupdate`:** + +```xml + + + + + + +``` + +Console output: + +``` +Migrated WindowsAzure.Storage -> Azure.Storage.Common (Version: 12.26.0) [Deprecated: Legacy] +Updated Newtonsoft.Json: 13.0.1 -> 13.0.3 +``` + + +### Migration Behavior + + +#### Automatic by Default + +Migrations happen automatically without requiring any flags or configuration. The tool detects deprecated packages and migrates them seamlessly. + + +#### Pinned Packages Are Never Migrated + +If a package is pinned, it will not be migrated even if it's deprecated: + +```xml + +``` + +This package will remain unchanged. + + +#### When Alternative Already Exists + +If the alternative package already exists in `Directory.Packages.props`, the migration is skipped: + +```xml + + + + + + +``` + +Output: + +``` +Package WindowsAzure.Storage is deprecated with alternative Azure.Storage.Common, but alternative already exists +``` + +Both packages remain in the file, and only `Azure.Storage.Common` gets updated to the latest version. + + +#### When No Alternative is Available + +If a package is deprecated but has no alternative specified, the tool logs a warning and continues with normal version update: + +``` +Package SomeDeprecatedPackage is deprecated but has no alternative. Reasons: Legacy +``` + + +#### Current Version Check + +The tool only migrates if the **current** version you're using is deprecated. If you're on an older, non-deprecated version, and only newer versions are deprecated, no migration occurs. This prevents unnecessary migrations when you're deliberately staying on an older version. + + +#### Specific Package Flag + +The migration feature works with the `--package` flag: + +```bash +packageupdate --package WindowsAzure.Storage +``` + +If `WindowsAzure.Storage` is deprecated with an alternative, it will be migrated automatically. + + +### Common Scenarios + + +#### Scenario 1: Microsoft Azure SDK Packages + +Many older Azure SDK packages have been deprecated in favor of the new Azure SDK: + +- `WindowsAzure.Storage` → `Azure.Storage.Common` or `Azure.Storage.Blobs` +- `Microsoft.Azure.Storage.Blob` → `Azure.Storage.Blobs` +- `Microsoft.Azure.DocumentDB` → `Microsoft.Azure.Cosmos` + +These migrations happen automatically when you run `packageupdate`. + + +#### Scenario 2: Preventing Migration + +If you want to prevent migration of a deprecated package (e.g., you're not ready to migrate yet), pin the package: + +```xml + + +``` + + +#### Scenario 3: Manual Review After Migration + +After automatic migration, you may want to: + +1. Review the changes in `Directory.Packages.props` +2. Update your code to use the new package's API (if breaking changes exist) +3. Test thoroughly before committing + +The migration updates the package reference but doesn't modify your source code. + + +### Logging + + +#### Successful Migration + +``` +Migrated WindowsAzure.Storage -> Azure.Storage.Common (Version: 12.26.0) [Deprecated: Legacy] +``` + + +#### Migration Skipped (Alternative Exists) + +``` +Package WindowsAzure.Storage is deprecated with alternative Azure.Storage.Common, but alternative already exists +``` + + +#### Migration Skipped (No Alternative) + +``` +Package MyOldPackage is deprecated but has no alternative. Reasons: Legacy, Critical Bugs +``` + + +#### Migration Skipped (Alternative Not Found) + +``` +Package OldPackage is deprecated with alternative NewPackage, but alternative not found in sources +``` + + +### Technical Details + +- Migration uses NuGet's official deprecation metadata API (`PackageDeprecationMetadata`) +- The `AlternatePackage` information comes directly from package authors via NuGet.org +- File formatting, comments, and XML structure are preserved during migration +- Migrations are logged distinctly from version updates for clarity + + ## Icon [Update](https://thenounproject.com/search/?q=update&i=2060555) by [Andy Miranda](https://thenounproject.com/andylontuan88) from [The Noun Project](https://thenounproject.com/). diff --git a/readme.source.md b/readme.source.md index ce7fef0a..469d1eec 100644 --- a/readme.source.md +++ b/readme.source.md @@ -292,6 +292,199 @@ The next time you run the updater, it will update to the latest version. - Comments and formatting around pinned packages are preserved during updates +## Automatic Package Migration + + +### Overview + +PackageUpdate automatically detects and migrates deprecated NuGet packages to their recommended alternatives. When a package is marked as deprecated on NuGet.org with an alternative package specified, the tool will automatically replace it during updates. + + +### How It Works + +When updating packages, PackageUpdate: + +1. Checks if the **current version** of each package is marked as deprecated +2. If an alternative package is specified in the deprecation metadata: + - Verifies the alternative package exists in configured NuGet sources + - Checks that the alternative doesn't already exist in `Directory.Packages.props` + - Replaces the package reference with the alternative + - Sets the version to the latest available version of the alternative +3. Logs the migration with the deprecation reason + + +### Example Migration + +**Before:** + +```xml + + + + + + +``` + +**After running `packageupdate`:** + +```xml + + + + + + +``` + +Console output: + +``` +Migrated WindowsAzure.Storage -> Azure.Storage.Common (Version: 12.26.0) [Deprecated: Legacy] +Updated Newtonsoft.Json: 13.0.1 -> 13.0.3 +``` + + +### Migration Behavior + + +#### Automatic by Default + +Migrations happen automatically without requiring any flags or configuration. The tool detects deprecated packages and migrates them seamlessly. + + +#### Pinned Packages Are Never Migrated + +If a package is pinned, it will not be migrated even if it's deprecated: + +```xml + +``` + +This package will remain unchanged. + + +#### When Alternative Already Exists + +If the alternative package already exists in `Directory.Packages.props`, the migration is skipped: + +```xml + + + + + + +``` + +Output: + +``` +Package WindowsAzure.Storage is deprecated with alternative Azure.Storage.Common, but alternative already exists +``` + +Both packages remain in the file, and only `Azure.Storage.Common` gets updated to the latest version. + + +#### When No Alternative is Available + +If a package is deprecated but has no alternative specified, the tool logs a warning and continues with normal version update: + +``` +Package SomeDeprecatedPackage is deprecated but has no alternative. Reasons: Legacy +``` + + +#### Current Version Check + +The tool only migrates if the **current** version you're using is deprecated. If you're on an older, non-deprecated version, and only newer versions are deprecated, no migration occurs. This prevents unnecessary migrations when you're deliberately staying on an older version. + + +#### Specific Package Flag + +The migration feature works with the `--package` flag: + +```bash +packageupdate --package WindowsAzure.Storage +``` + +If `WindowsAzure.Storage` is deprecated with an alternative, it will be migrated automatically. + + +### Common Scenarios + + +#### Scenario 1: Microsoft Azure SDK Packages + +Many older Azure SDK packages have been deprecated in favor of the new Azure SDK: + +- `WindowsAzure.Storage` → `Azure.Storage.Common` or `Azure.Storage.Blobs` +- `Microsoft.Azure.Storage.Blob` → `Azure.Storage.Blobs` +- `Microsoft.Azure.DocumentDB` → `Microsoft.Azure.Cosmos` + +These migrations happen automatically when you run `packageupdate`. + + +#### Scenario 2: Preventing Migration + +If you want to prevent migration of a deprecated package (e.g., you're not ready to migrate yet), pin the package: + +```xml + + +``` + + +#### Scenario 3: Manual Review After Migration + +After automatic migration, you may want to: + +1. Review the changes in `Directory.Packages.props` +2. Update your code to use the new package's API (if breaking changes exist) +3. Test thoroughly before committing + +The migration updates the package reference but doesn't modify your source code. + + +### Logging + + +#### Successful Migration + +``` +Migrated WindowsAzure.Storage -> Azure.Storage.Common (Version: 12.26.0) [Deprecated: Legacy] +``` + + +#### Migration Skipped (Alternative Exists) + +``` +Package WindowsAzure.Storage is deprecated with alternative Azure.Storage.Common, but alternative already exists +``` + + +#### Migration Skipped (No Alternative) + +``` +Package MyOldPackage is deprecated but has no alternative. Reasons: Legacy, Critical Bugs +``` + + +#### Migration Skipped (Alternative Not Found) + +``` +Package OldPackage is deprecated with alternative NewPackage, but alternative not found in sources +``` + + +### Technical Details + +- Migration uses NuGet's official deprecation metadata API (`PackageDeprecationMetadata`) +- The `AlternatePackage` information comes directly from package authors via NuGet.org +- File formatting, comments, and XML structure are preserved during migration +- Migrations are logged distinctly from version updates for clarity + + ## Icon [Update](https://thenounproject.com/search/?q=update&i=2060555) by [Andy Miranda](https://thenounproject.com/andylontuan88) from [The Noun Project](https://thenounproject.com/). diff --git a/src/PackageUpdate/Updater.cs b/src/PackageUpdate/Updater.cs index 6ee03bdf..eef26f8b 100644 --- a/src/PackageUpdate/Updater.cs +++ b/src/PackageUpdate/Updater.cs @@ -51,6 +51,35 @@ public static async Task Update( continue; } + // Check if current version is deprecated and attempt migration + var currentMetadata = await GetPackageMetadata( + package.Package!, + currentVersion, + sources, + cache); + + if (currentMetadata != null) + { + var deprecation = await currentMetadata.GetDeprecationMetadataAsync(); + if (deprecation != null) + { + var migrated = await TryMigratePackage( + package.Element, + package.Package!, + deprecation, + sources, + cache, + xml); + + if (migrated) + { + // Migration successful, skip normal version update + continue; + } + // If migration failed, continue with normal version update + } + } + var latestMetadata = await GetLatestVersion( package.Package!, currentVersion, @@ -176,6 +205,102 @@ public static async Task Update( return latestMetadata; } + static async Task GetPackageMetadata( + string package, + NuGetVersion version, + List sources, + SourceCacheContext cache) + { + foreach (var source in sources) + { + var (_, metadataResource) = await RepositoryReader.Read(source); + + var metadata = await metadataResource.GetMetadataAsync( + new(package, version), + cache, + SerilogNuGetLogger.Instance, + Cancel.None); + + if (metadata != null) + { + return metadata; + } + } + + return null; + } + + static async Task TryMigratePackage( + XElement packageElement, + string currentPackage, + PackageDeprecationMetadata deprecation, + List sources, + SourceCacheContext cache, + XDocument xml) + { + // Check if alternate package exists + var alternatePackage = deprecation.AlternatePackage; + if (alternatePackage == null) + { + Log.Warning( + "Package {Package} is deprecated but has no alternative. Reasons: {Reasons}", + currentPackage, + string.Join(", ", deprecation.Reasons)); + return false; + } + + // Check if alternate already exists in Directory.Packages.props + var existingAlternate = xml.Descendants("PackageVersion") + .FirstOrDefault(_ => + string.Equals( + _.Attribute("Include")?.Value, + alternatePackage.PackageId, + StringComparison.OrdinalIgnoreCase)); + + if (existingAlternate != null) + { + Log.Warning( + "Package {Package} is deprecated with alternative {Alternative}, but alternative already exists", + currentPackage, + alternatePackage.PackageId); + return false; + } + + // Verify alternate package exists in NuGet sources + var alternateMetadata = await GetLatestVersion( + alternatePackage.PackageId, + // Start from 0.0.0 to get any version + new(0, 0, 0), + sources, + cache); + + if (alternateMetadata == null) + { + Log.Warning( + "Package {Package} is deprecated with alternative {Alternative}, but alternative not found in sources", + currentPackage, + alternatePackage.PackageId); + return false; + } + + // Perform migration: update Include attribute and Version + packageElement.SetAttributeValue("Include", alternatePackage.PackageId); + + // Use the minimum version from the range if specified, + // otherwise use the latest version we found + var targetVersion = alternatePackage.Range?.MinVersion ?? alternateMetadata.Identity.Version; + packageElement.SetAttributeValue("Version", targetVersion.ToString()); + + Log.Information( + "Migrated {OldPackage} -> {NewPackage} (Version: {Version}) [Deprecated: {Reasons}]", + currentPackage, + alternatePackage.PackageId, + targetVersion, + string.Join(", ", deprecation.Reasons)); + + return true; + } + static async Task> GetCondidates(string package, NuGetVersion currentVersion, SourceCacheContext cache, SourceRepository repository) { // Use FindPackageByIdResource to efficiently get version list @@ -188,7 +313,7 @@ static async Task> GetCondidates(string package, NuGetVersion Cancel.None); return versions - .Where(v => ShouldConsiderVersion(v, currentVersion)) + .Where(_ => ShouldConsiderVersion(_, currentVersion)) .OrderByDescending(_ => _) .ToList(); } @@ -204,4 +329,4 @@ static bool ShouldConsiderVersion(NuGetVersion candidate, NuGetVersion current) return candidate > current; } -} \ No newline at end of file +} diff --git a/src/Tests/UpdaterTests.SkipsMigrationWhenAlternativeAlreadyExists.verified.txt b/src/Tests/UpdaterTests.SkipsMigrationWhenAlternativeAlreadyExists.verified.txt new file mode 100644 index 00000000..31bfc951 --- /dev/null +++ b/src/Tests/UpdaterTests.SkipsMigrationWhenAlternativeAlreadyExists.verified.txt @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/Tests/UpdaterTests.cs b/src/Tests/UpdaterTests.cs index 348ce76a..c5b8f45c 100644 --- a/src/Tests/UpdaterTests.cs +++ b/src/Tests/UpdaterTests.cs @@ -422,7 +422,7 @@ public async Task UpdateSkipsUnlistedVersions() var doc = XDocument.Parse(result); var packageVersion = doc.Descendants("PackageVersion") - .FirstOrDefault(e => e.Attribute("Include")?.Value == "YoloDev.Expecto.TestSdk"); + .FirstOrDefault(_ => _.Attribute("Include")?.Value == "YoloDev.Expecto.TestSdk"); Assert.NotNull(packageVersion); @@ -760,4 +760,131 @@ public async Task UpdatePreservesNoTrailingNewlineWithCRLF() Assert.NotEqual((byte)'\n', resultBytes[^1]); Assert.NotEqual((byte)'\r', resultBytes[^1]); } + + [Fact] + public async Task MigratesDeprecatedPackageWithAlternative() + { + using var cache = new SourceCacheContext { RefreshMemoryCache = true }; + var content = + """ + + + + + + """; + + using var tempFile = await TempFile.CreateText(content); + + await Updater.Update(cache, tempFile.Path, null); + + var result = await File.ReadAllTextAsync(tempFile.Path); + var doc = XDocument.Parse(result); + + var packages = doc.Descendants("PackageVersion") + .Select(element => new + { + Id = element.Attribute("Include")?.Value, + Version = element.Attribute("Version")?.Value + }) + .ToList(); + + // Original package should be migrated to the alternative + Assert.DoesNotContain(packages, _ => _.Id == "WindowsAzure.Storage"); + + // Alternative package should exist + var alternativePackage = packages.FirstOrDefault(_ => _.Id == "Azure.Storage.Common" || _.Id == "Azure.Storage.Blobs"); + Assert.NotNull(alternativePackage); + Assert.NotNull(alternativePackage.Version); + } + + [Fact] + public async Task SkipsMigrationWhenAlternativeAlreadyExists() + { + using var cache = new SourceCacheContext { RefreshMemoryCache = true }; + var content = + """ + + + + + + + """; + + using var tempFile = await TempFile.CreateText(content); + + await Updater.Update(cache, tempFile.Path, null); + + var result = await File.ReadAllTextAsync(tempFile.Path); + + // Use Verify to see the actual output + await Verify(result); + } + + [Fact] + public async Task PinnedDeprecatedPackageNotMigrated() + { + using var cache = new SourceCacheContext { RefreshMemoryCache = true }; + var content = + """ + + + + + + """; + + using var tempFile = await TempFile.CreateText(content); + + await Updater.Update(cache, tempFile.Path, null); + + var result = await File.ReadAllTextAsync(tempFile.Path); + var doc = XDocument.Parse(result); + + var packages = doc.Descendants("PackageVersion") + .Select(element => new + { + Id = element.Attribute("Include")?.Value, + Version = element.Attribute("Version")?.Value, + Pinned = element.Attribute("Pinned")?.Value + }) + .ToList(); + + // Pinned package should not be migrated + var pinnedPackage = packages.FirstOrDefault(_ => _.Id == "WindowsAzure.Storage"); + Assert.NotNull(pinnedPackage); + Assert.Equal("9.3.3", pinnedPackage.Version); + Assert.Equal("true", pinnedPackage.Pinned); + } + + [Fact] + public async Task MigrationPreservesFormattingAndComments() + { + using var cache = new SourceCacheContext { RefreshMemoryCache = true }; + var content = + """ + + + + + + + + + """; + + using var tempFile = await TempFile.CreateText(content); + + await Updater.Update(cache, tempFile.Path, null); + + var result = await File.ReadAllTextAsync(tempFile.Path); + + // Verify comments are preserved + Assert.Contains("", result); + Assert.Contains("", result); + + // Verify the package was migrated + Assert.DoesNotContain("WindowsAzure.Storage", result); + } } \ No newline at end of file