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