Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
### v1.0.0-preview.21 - 26 June 2026
* Add: `--allow-empty-types` option for plugin sync, keeping plugin types registered when they no longer have steps instead of deleting them (avoids forcing a full upgrade of managed solutions)

### v1.0.0-preview.20 - 26 June 2026
* Integrate managed identity handling in Plugin sync and make it more resilient

Expand Down
3 changes: 2 additions & 1 deletion Model/Plugin/PluginSyncCommandOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ public record PluginSyncCommandOptions(
string AssemblyPath,
string SolutionName,
string? ManagedIdentityClientId = null,
string? ManagedIdentityTenantId = null)
string? ManagedIdentityTenantId = null,
bool AllowEmptyTypes = false)
{
public static PluginSyncCommandOptions Empty => new(string.Empty, string.Empty);

Expand Down
2 changes: 1 addition & 1 deletion Model/XrmSyncOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ public abstract record SyncItem
public abstract string SyncType { get; }
}

public record PluginSyncItem(string AssemblyPath, string? ManagedIdentityClientId = null, string? ManagedIdentityTenantId = null) : SyncItem
public record PluginSyncItem(string AssemblyPath, string? ManagedIdentityClientId = null, string? ManagedIdentityTenantId = null, bool AllowEmptyTypes = false) : SyncItem
{
public const string TypeName = "Plugin";
public static PluginSyncItem Empty => new(string.Empty);
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ The root command accepts the following override options in addition to `--dry-ru
| `--prefix` | Plugin analysis |
| `--client-id` | Plugin sync, Identity (Ensure) |
| `--tenant-id` | Plugin sync, Identity (Ensure) |
| `--allow-empty-types` | Plugin sync |

### Command Line Options

Expand All @@ -135,6 +136,7 @@ The root command accepts the following override options in addition to `--dry-ru
| `--solution-name` | `-n` | Name of the target Dataverse solution | Yes* |
| `--client-id` | `--cid` | Azure AD application (client) ID for the managed identity to ensure on the assembly | No** |
| `--tenant-id` | `--tid` | Azure AD tenant ID for the managed identity to ensure on the assembly | No** |
| `--allow-empty-types` | `--allow-empty`, `--aet` | Keep plugin types registered when they no longer have steps instead of deleting them (avoids forcing a full upgrade of managed solutions) | No |
| `--dry-run` | | Perform a dry run without making changes | No |
| `--log-level` | `-l` | Set the minimum log level (Trace, Debug, Information, Warning, Error, Critical) | No |
| `--ci-mode` | `--ci` | Enable CI mode which prefixes all warnings and errors | No |
Expand Down
16 changes: 15 additions & 1 deletion SyncService/Difference/DifferenceCalculator.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Microsoft.Extensions.Options;
using System.Linq.Expressions;
using XrmSync.Model;
using XrmSync.Model.CustomApi;
Expand All @@ -14,8 +15,11 @@ internal class DifferenceCalculator(
IEntityComparer<CustomApiDefinition> customApiComparer,
IEntityComparer<RequestParameter> requestComparer,
IEntityComparer<ResponseProperty> responseComparer,
IPrintService printer) : IDifferenceCalculator
IPrintService printer,
IOptions<PluginSyncCommandOptions> options) : IDifferenceCalculator
{
private readonly bool allowEmptyTypes = options.Value.AllowEmptyTypes;

public Differences CalculateDifferences(AssemblyInfo localData, AssemblyInfo? remoteData)
{
var pluginTypes = ComputePluginTypeDiffs(localData, remoteData);
Expand All @@ -36,6 +40,16 @@ public Differences CalculateDifferences(AssemblyInfo localData, AssemblyInfo? re
private Difference<PluginDefinition> ComputePluginTypeDiffs(AssemblyInfo localData, AssemblyInfo? remoteData)
{
var result = CompareFlatEntities(localData.Plugins, remoteData?.Plugins ?? [], pluginDefinitionComparer);

if (allowEmptyTypes && result.Deletes.Count > 0)
{
// When empty types are allowed, keep plugin types registered even when they no longer
// have any steps locally. Their steps are still removed (as orphaned step deletes),
// leaving an empty type behind. This avoids forcing a full upgrade of a managed solution,
// where deleting a component would otherwise require one.
result = result with { Deletes = [] };
}

printer.Print(result, "Types", x => x.Name);
return result;
}
Expand Down
4 changes: 3 additions & 1 deletion Tests/CustomApis/DifferenceCalculatorCustomApiTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using Microsoft.Extensions.Options;
using XrmSync.Model;
using XrmSync.Model.CustomApi;
using XrmSync.Model.Plugin;
using XrmSync.SyncService;
using XrmSync.SyncService.Comparers;
using XrmSync.SyncService.Difference;
Expand All @@ -28,7 +29,8 @@ public DifferenceCalculatorCustomApiTests()
new CustomApiComparer(description),
new RequestParameterComparer(),
new ResponsePropertyComparer(),
new PrintService(logger, Options.Create(options))
new PrintService(logger, Options.Create(options)),
Options.Create(PluginSyncCommandOptions.Empty)
);
}

Expand Down
41 changes: 39 additions & 2 deletions Tests/Plugins/DifferenceUtilityTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,24 @@ public class DifferenceUtilityTests
private readonly DifferenceCalculator differenceUtility;

public DifferenceUtilityTests()
{
differenceUtility = CreateCalculator();
}

private static DifferenceCalculator CreateCalculator(bool allowEmptyTypes = false)
{
var logger = new LoggerFactory().CreateLogger<PrintService>();
var description = new Description();
var options = new ExecutionContext(null, true, null, null, null);
differenceUtility = new DifferenceCalculator(
return new DifferenceCalculator(
new PluginDefinitionComparer(),
new PluginStepComparer(),
new PluginImageComparer(),
new CustomApiComparer(description),
new RequestParameterComparer(),
new ResponsePropertyComparer(),
new PrintService(logger, Options.Create(options))
new PrintService(logger, Options.Create(options)),
Options.Create(PluginSyncCommandOptions.Empty with { AllowEmptyTypes = allowEmptyTypes })
);
}

Expand Down Expand Up @@ -333,6 +339,37 @@ public void CalculateDifferencesDeletesRemoteSteplessTypeNotInLocal()
Assert.Empty(differences.Types.Creates);
}

[Fact]
public void CalculateDifferencesKeepsRemoteTypeNotInLocalWhenAllowEmptyTypes()
{
// Arrange — remote has a type that no longer exists locally. With AllowEmptyTypes
// enabled the type must NOT be deleted (it is kept as an empty type), so a managed
// solution is not forced into a full upgrade.
var remoteOrphanedType = new PluginDefinition("Namespace.OrphanedType") { Id = Guid.NewGuid(), PluginSteps = [] };
var sharedType = new PluginDefinition("Namespace.SharedPlugin") { Id = Guid.NewGuid(), PluginSteps = [] };

var localData = new AssemblyInfo("TestAssembly")
{
DllPath = "test.dll",
Hash = "hash",
Version = "1.0.0",
Plugins = [sharedType],
CustomApis = []
};

var remoteData = localData with
{
Plugins = [sharedType, remoteOrphanedType]
};

// Act
var differences = CreateCalculator(allowEmptyTypes: true).CalculateDifferences(localData, remoteData);

// Assert — the orphaned type is preserved, not deleted
Assert.Empty(differences.Types.Deletes);
Assert.Empty(differences.Types.Creates);
}

[Fact]
public void CalculateDifferencesDoesNotDeleteSteplessTypeWhenInjectedLocally()
{
Expand Down
1 change: 1 addition & 0 deletions XrmSync/Commands/CommandOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ namespace XrmSync.Commands;
internal static class CommandOptions
{
public static readonly Option<string?> Assembly = CliOptions.Assembly.CreateOption<string?>();
public static readonly Option<bool?> AllowEmptyTypes = CliOptions.AllowEmptyTypes.CreateOption<bool?>();
public static readonly Option<string?> Solution = CliOptions.Solution.CreateOption<string?>();
public static readonly Option<string?> Folder = CliOptions.Webresource.CreateOption<string?>();
public static readonly Option<string[]?> FileExtensions = CliOptions.FileExtensions.CreateOption<string[]?>();
Expand Down
12 changes: 9 additions & 3 deletions XrmSync/Commands/PluginSyncCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public PluginSyncCommand() : base("plugins", "Synchronize plugins in a plugin as
Add(CommandOptions.Assembly);
Add(CommandOptions.ClientId);
Add(CommandOptions.TenantId);
Add(CommandOptions.AllowEmptyTypes);

AddSharedOptions();
AddSyncOptions();
Expand All @@ -27,7 +28,7 @@ public PluginSyncCommand() : base("plugins", "Synchronize plugins in a plugin as
public override async Task<int?> ExecuteFromProfile(SyncItem syncItem, ExecutionContext ctx, CancellationToken ct)
{
if (syncItem is not PluginSyncItem plugin) return null;
return await RunCore(plugin.AssemblyPath, ctx.SolutionName ?? string.Empty, plugin.ManagedIdentityClientId, plugin.ManagedIdentityTenantId, ctx.DryRun, ctx.CiMode, ctx.LogLevel, ctx.ProfileName, ct);
return await RunCore(plugin.AssemblyPath, ctx.SolutionName ?? string.Empty, plugin.ManagedIdentityClientId, plugin.ManagedIdentityTenantId, plugin.AllowEmptyTypes, ctx.DryRun, ctx.CiMode, ctx.LogLevel, ctx.ProfileName, ct);
}

private async Task<int> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
Expand All @@ -36,6 +37,7 @@ private async Task<int> ExecuteAsync(ParseResult parseResult, CancellationToken
var solutionName = parseResult.GetValue(CommandOptions.Solution);
var clientId = parseResult.GetValue(CommandOptions.ClientId);
var tenantId = parseResult.GetValue(CommandOptions.TenantId);
var allowEmptyTypes = parseResult.GetValue(CommandOptions.AllowEmptyTypes);
var dryRun = parseResult.GetValue(CommandOptions.DryRun);
var logLevel = parseResult.GetValue(CommandOptions.LogLevel);
var ciMode = parseResult.GetValue(CommandOptions.CiMode);
Expand All @@ -46,6 +48,7 @@ private async Task<int> ExecuteAsync(ParseResult parseResult, CancellationToken
string finalSolutionName;
string? finalClientId;
string? finalTenantId;
bool finalAllowEmptyTypes;

if (profileName == null && !string.IsNullOrWhiteSpace(assemblyPath) && !string.IsNullOrWhiteSpace(solutionName))
{
Expand All @@ -54,6 +57,7 @@ private async Task<int> ExecuteAsync(ParseResult parseResult, CancellationToken
finalSolutionName = solutionName;
finalClientId = clientId;
finalTenantId = tenantId;
finalAllowEmptyTypes = allowEmptyTypes ?? false;
}
else
{
Expand All @@ -75,16 +79,18 @@ private async Task<int> ExecuteAsync(ParseResult parseResult, CancellationToken
finalSolutionName = solutionName.GetValueOrDefault(profile.SolutionName);
finalClientId = clientId.GetValueOrDefault(pluginSyncItem?.ManagedIdentityClientId ?? string.Empty);
finalTenantId = tenantId.GetValueOrDefault(pluginSyncItem?.ManagedIdentityTenantId ?? string.Empty);
finalAllowEmptyTypes = allowEmptyTypes ?? pluginSyncItem?.AllowEmptyTypes ?? false;
}

return await RunCore(finalAssemblyPath, finalSolutionName, finalClientId, finalTenantId, dryRun, ciMode, logLevel, profileName, cancellationToken);
return await RunCore(finalAssemblyPath, finalSolutionName, finalClientId, finalTenantId, finalAllowEmptyTypes, dryRun, ciMode, logLevel, profileName, cancellationToken);
}

private async Task<int> RunCore(
string assemblyPath,
string solutionName,
string? managedIdentityClientId,
string? managedIdentityTenantId,
bool allowEmptyTypes,
bool? dryRun,
bool? ciMode,
LogLevel? logLevel,
Expand Down Expand Up @@ -116,7 +122,7 @@ private async Task<int> RunCore(
CiMode = ciMode ?? baseOptions.CiMode,
DryRun = dryRun ?? baseOptions.DryRun
})
.AddSingleton(MSOptions.Create(new PluginSyncCommandOptions(assemblyPath, solutionName, managedIdentityClientId, managedIdentityTenantId)))
.AddSingleton(MSOptions.Create(new PluginSyncCommandOptions(assemblyPath, solutionName, managedIdentityClientId, managedIdentityTenantId, allowEmptyTypes)))
.AddLogger()
.BuildServiceProvider();

Expand Down
5 changes: 4 additions & 1 deletion XrmSync/Commands/XrmSyncRootCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public XrmSyncRootCommand(List<IXrmSyncCommand> subCommands)
Add(CommandOptions.Operation);
Add(CommandOptions.ClientId);
Add(CommandOptions.TenantId);
Add(CommandOptions.AllowEmptyTypes);

AddSharedOptions();
SetAction(ExecuteAsync);
Expand All @@ -50,6 +51,7 @@ private async Task<int> ExecuteAsync(ParseResult parseResult, CancellationToken
var operationOverride = parseResult.GetValue(CommandOptions.Operation);
var clientIdOverride = parseResult.GetValue(CommandOptions.ClientId);
var tenantIdOverride = parseResult.GetValue(CommandOptions.TenantId);
var allowEmptyTypesOverride = parseResult.GetValue(CommandOptions.AllowEmptyTypes);

ProfileConfiguration? rawProfile;
XrmSyncConfiguration rawConfig;
Expand All @@ -76,7 +78,8 @@ private async Task<int> ExecuteAsync(ParseResult parseResult, CancellationToken
{
AssemblyPath = assemblyOverride.GetValueOrDefault(plugin.AssemblyPath),
ManagedIdentityClientId = clientIdOverride.GetValueOrDefault(plugin.ManagedIdentityClientId ?? string.Empty),
ManagedIdentityTenantId = tenantIdOverride.GetValueOrDefault(plugin.ManagedIdentityTenantId ?? string.Empty)
ManagedIdentityTenantId = tenantIdOverride.GetValueOrDefault(plugin.ManagedIdentityTenantId ?? string.Empty),
AllowEmptyTypes = allowEmptyTypesOverride ?? plugin.AllowEmptyTypes
},
PluginAnalysisSyncItem analysis => analysis with
{
Expand Down
8 changes: 8 additions & 0 deletions XrmSync/Constants/CliOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ internal static class CliOptions
"Path to the plugin assembly (*.dll)",
Arity: System.CommandLine.ArgumentArity.ZeroOrOne);

/// <summary>
/// Allow empty plugin types (keep types that no longer have steps instead of deleting them)
/// </summary>
public static readonly CliOptionDescriptor AllowEmptyTypes = new(
"--allow-empty-types", ["--allow-empty", "--aet"],
"Keep plugin types registered even when they no longer have any steps, instead of deleting them. Useful for managed solutions where deleting a component would require a full upgrade.",
Arity: System.CommandLine.ArgumentArity.ZeroOrOne);

/// <summary>
/// Webresource folder options
/// </summary>
Expand Down