diff --git a/README.md b/README.md index c79092cd..a0c4969b 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,27 @@ txc env component layer list --entity account --attribute revenue txc env component dep delete-check --entity tom_project ``` +**Pulling changes back from the environment:** + +```sh +# First time: scaffold a project from an existing solution and pull its current state +txc env sln clone MySolution --output ./src/MySolution/ + +# From then on: sync server-side changes into the existing project +cd ./src/MySolution/ +txc env sln pull +``` + +Pull treats your local `Solution.xml` as the source of truth for what the solution contains, while the environment stays the source of truth for component content. On every pull the export is normalized before it touches your repo: + +- server-added system relationships (owner, business unit, team, createdby/modifiedby lookups) and `OrganizationVersion`-style attributes are stripped, so the diff only shows real changes +- classic components (entities, option sets, workflows, web resources, roles, apps, plugins, steps) land only when they're declared in `RootComponents` or already exist in the project — to accept something added server-side, declare it and pull again. SCF components and site maps aren't filtered +- subcomponents follow their entity: `behavior="0"` pulls everything and declares pulled forms as explicit root components, `behavior="1"`/`"2"` stops new server-side subcomponents while keeping everything already pulled. Views never appear in the manifest +- entity attributes that aren't custom fields and aren't present in your `Entity.xml` are dropped, so a trimmed-down entity stays trimmed across roundtrips +- binaries built from `ProjectReference`s (plugin assemblies, script-library web resources, PCF controls) stay out of the solution folder — they're build artifacts, not source + +A fresh clone adopts the server state once; every pull after that enforces what the manifest declares. `git status` after a repeated pull with no server changes is empty — that's the contract. + ### Data Plane Query, create, update, and bulk-operate on Dataverse records. diff --git a/src/TALXIS.CLI.Core/Contracts/Dataverse/ISolutionPullService.cs b/src/TALXIS.CLI.Core/Contracts/Dataverse/ISolutionPullService.cs index d75b714c..c57d342c 100644 --- a/src/TALXIS.CLI.Core/Contracts/Dataverse/ISolutionPullService.cs +++ b/src/TALXIS.CLI.Core/Contracts/Dataverse/ISolutionPullService.cs @@ -12,7 +12,8 @@ public sealed record SolutionPullResult( IReadOnlyList ExcludedBinaries, IReadOnlyList ExcludedWebResources, IReadOnlyList ExcludedPcfControls, - IReadOnlyList RemovedFiles); + IReadOnlyList RemovedFiles, + IReadOnlyList NormalizationChanges); public interface ISolutionPullService { diff --git a/src/TALXIS.CLI.Core/TALXIS.CLI.Core.csproj b/src/TALXIS.CLI.Core/TALXIS.CLI.Core.csproj index b0a8f6e8..10895492 100644 --- a/src/TALXIS.CLI.Core/TALXIS.CLI.Core.csproj +++ b/src/TALXIS.CLI.Core/TALXIS.CLI.Core.csproj @@ -9,7 +9,7 @@ - + diff --git a/src/TALXIS.CLI.Features.Environment/Solution/SolutionCloneCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Solution/SolutionCloneCliCommand.cs index 02a31e0b..8b892b28 100644 --- a/src/TALXIS.CLI.Features.Environment/Solution/SolutionCloneCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/Solution/SolutionCloneCliCommand.cs @@ -130,6 +130,7 @@ protected override async Task ExecuteAsync() excludedWebResources = result.ExcludedWebResources, excludedPcfControls = result.ExcludedPcfControls, removedFiles = result.RemovedFiles, + normalizationChanges = result.NormalizationChanges, }, _ => { @@ -145,6 +146,8 @@ protected override async Task ExecuteAsync() OutputWriter.WriteLine($" Excluded {result.ExcludedWebResources.Count} script-library web resource(s)"); if (result.ExcludedPcfControls.Count > 0) OutputWriter.WriteLine($" Excluded {result.ExcludedPcfControls.Count} PCF control(s)"); + if (result.NormalizationChanges.Count > 0) + OutputWriter.WriteLine($" Applied {result.NormalizationChanges.Count} normalization change(s)"); #pragma warning restore TXC003 }); diff --git a/src/TALXIS.CLI.Features.Environment/Solution/SolutionPullCliCommand.cs b/src/TALXIS.CLI.Features.Environment/Solution/SolutionPullCliCommand.cs index 15289c79..9f009b46 100644 --- a/src/TALXIS.CLI.Features.Environment/Solution/SolutionPullCliCommand.cs +++ b/src/TALXIS.CLI.Features.Environment/Solution/SolutionPullCliCommand.cs @@ -49,6 +49,7 @@ protected override async Task ExecuteAsync() excludedWebResources = result.ExcludedWebResources, excludedPcfControls = result.ExcludedPcfControls, removedFiles = result.RemovedFiles, + normalizationChanges = result.NormalizationChanges, }; OutputFormatter.WriteData(payload, _ => @@ -61,6 +62,7 @@ protected override async Task ExecuteAsync() WriteList("Excluded script-library web resource(s)", result.ExcludedWebResources); WriteList("Excluded PCF control(s)", result.ExcludedPcfControls); WriteList("Removed stale solution file(s)", result.RemovedFiles); + WriteList("Applied normalization change(s)", result.NormalizationChanges); static void WriteList(string label, IReadOnlyList items) { diff --git a/src/TALXIS.CLI.Features.Environment/TALXIS.CLI.Features.Environment.csproj b/src/TALXIS.CLI.Features.Environment/TALXIS.CLI.Features.Environment.csproj index b6bfeef1..ed1e587c 100644 --- a/src/TALXIS.CLI.Features.Environment/TALXIS.CLI.Features.Environment.csproj +++ b/src/TALXIS.CLI.Features.Environment/TALXIS.CLI.Features.Environment.csproj @@ -8,8 +8,8 @@ - - + + diff --git a/src/TALXIS.CLI.Features.Workspace/TALXIS.CLI.Features.Workspace.csproj b/src/TALXIS.CLI.Features.Workspace/TALXIS.CLI.Features.Workspace.csproj index 134884a4..31f32c86 100644 --- a/src/TALXIS.CLI.Features.Workspace/TALXIS.CLI.Features.Workspace.csproj +++ b/src/TALXIS.CLI.Features.Workspace/TALXIS.CLI.Features.Workspace.csproj @@ -18,8 +18,8 @@ - - + + diff --git a/src/TALXIS.CLI.Platform.Dataverse.Application/Pipeline/SolutionPullContext.cs b/src/TALXIS.CLI.Platform.Dataverse.Application/Pipeline/SolutionPullContext.cs index d166bda0..3ca8e911 100644 --- a/src/TALXIS.CLI.Platform.Dataverse.Application/Pipeline/SolutionPullContext.cs +++ b/src/TALXIS.CLI.Platform.Dataverse.Application/Pipeline/SolutionPullContext.cs @@ -11,6 +11,7 @@ internal sealed class SolutionPullContext public IReadOnlyCollection? ReferencedPcfControlNames { get; init; } public List NormalizedAssemblies { get; } = []; public List ExcludedRelationships { get; } = []; + public List NormalizationChanges { get; } = []; public List ExcludedBinaries { get; } = []; public List ExcludedWebResources { get; } = []; public List ExcludedPcfControls { get; } = []; diff --git a/src/TALXIS.CLI.Platform.Dataverse.Application/Pipeline/Steps/ExportNormalizationStep.cs b/src/TALXIS.CLI.Platform.Dataverse.Application/Pipeline/Steps/ExportNormalizationStep.cs new file mode 100644 index 00000000..4a3e30e4 --- /dev/null +++ b/src/TALXIS.CLI.Platform.Dataverse.Application/Pipeline/Steps/ExportNormalizationStep.cs @@ -0,0 +1,193 @@ +using TALXIS.Platform.Metadata; +using TALXIS.Platform.Metadata.Serialization.Xml; +using TALXIS.Platform.Metadata.Solutions; + +namespace TALXIS.CLI.Platform.Dataverse.Application.Pipeline.Steps; + +/// +/// Normalizes the unpacked export against the local source project via +/// : server-added system relationships, components outside +/// the source solution, and server-enriched attributes. +/// The local Solution.xml is the source of truth for solution composition: once it declares +/// root components, pull never overwrites it (bootstrap projects adopt the server manifest once). +/// +internal sealed class ExportNormalizationStep : ISolutionPullStep +{ + public void Execute(SolutionPullContext context) + { + var destinationSolutionXml = Path.Combine( + context.DestinationDirectory, + SolutionPullPipelineConstants.OtherDirectoryName, + "Solution.xml"); + if (!File.Exists(destinationSolutionXml)) + return; + + var reader = new XmlWorkspaceReader(); + var exported = reader.Load(context.StagingDirectory); + if (exported.Solutions.Count != 1) + return; + + var source = reader.Load(context.DestinationDirectory); + var sourceSolution = source.FindSolution(exported.Solutions[0].UniqueName); + if (sourceSolution is null + && source.Solutions.Count == 1 + && source.Solutions[0].RootComponents.Count == 0) + { + // Fresh clone scaffold: its unique name comes from the output folder, not the solution. + // Adopt it as the source so bootstrap pulls are still normalized. + sourceSolution = source.Solutions[0]; + sourceSolution.UniqueName = exported.Solutions[0].UniqueName; + } + + if (sourceSolution is null) + return; + + var preserveLocalManifest = sourceSolution.RootComponents.Count > 0; + var options = new ExportNormalizationOptions + { + NormalizeManagedFlag = !preserveLocalManifest, + NormalizeSolutionVersion = !preserveLocalManifest + }; + + var componentFiles = CaptureComponentFiles(exported); + + var result = new ExportNormalizer().Normalize(exported, source, options); + if (result.HasChanges) + { + new XmlWorkspaceWriter().Write(exported, context.StagingDirectory); + + foreach (var change in result.Changes) + { + if (change.ComponentType == ComponentType.EntityRelationship) + { + context.ExcludedRelationships.Add(change.Target); + continue; + } + + if (change.ComponentType is { } componentType + && componentFiles.TryGetValue(ComponentFileKey(componentType, change.Target), out var filePath)) + { + DeleteComponentFiles(context.StagingDirectory, componentType, filePath); + } + + context.NormalizationChanges.Add(change.Description); + } + } + + if (preserveLocalManifest) + { + var stagingSolutionXml = Path.Combine( + context.StagingDirectory, + SolutionPullPipelineConstants.OtherDirectoryName, + "Solution.xml"); + File.Copy(destinationSolutionXml, stagingSolutionXml, overwrite: true); + + if (DeclareDownloadedSubcomponents(exported, sourceSolution, context) > 0) + new XmlWorkspaceWriter().WriteSolutionManifest(source, sourceSolution.UniqueName, context.StagingDirectory); + } + else if (DeclareDownloadedSubcomponents(exported, exported.Solutions[0], context) > 0) + { + // Bootstrap adopts the server manifest; declare the pulled forms in it right away + // so a fresh clone does not need a second pull to reach the steady state. + new XmlWorkspaceWriter().WriteSolutionManifest(exported, exported.Solutions[0].UniqueName, context.StagingDirectory); + } + } + + // Forms pulled with a behavior=0 entity become explicit RootComponents in the local manifest, + // so they survive a later switch of the entity to behavior 1/2. Views are plain subcomponents + // and never appear in the manifest — they always follow their entity. + private static int DeclareDownloadedSubcomponents(Workspace exported, Solution sourceSolution, SolutionPullContext context) + { + var includedEntities = new HashSet( + sourceSolution.RootComponents + .Where(rc => rc.Type == ComponentType.Entity + && rc.BehaviorOption == RootComponentBehavior.IncludeSubcomponents + && !string.IsNullOrWhiteSpace(rc.SchemaName)) + .Select(rc => rc.SchemaName!), + StringComparer.OrdinalIgnoreCase); + + var declared = 0; + + foreach (var form in exported.Forms) + { + if (form.EntityLogicalName is null || !includedEntities.Contains(form.EntityLogicalName)) continue; + if (!Guid.TryParse(form.FormId, out var formId)) continue; + if (sourceSolution.RootComponents.Any(rc => rc.Type == ComponentType.SystemForm && rc.Id == formId)) continue; + + sourceSolution.AddRootComponent(new RootComponent { Type = ComponentType.SystemForm, Id = formId, Behavior = 0 }); + context.NormalizationChanges.Add($"Declared form '{form.DisplayName.Default ?? form.FormId}' of entity '{form.EntityLogicalName}' as a root component."); + declared++; + } + + return declared; + } + + private static Dictionary CaptureComponentFiles(Workspace exported) + { + var files = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var entity in exported.Entities) Add(files, ComponentType.Entity, entity.LogicalName, entity); + foreach (var optionSet in exported.GlobalOptionSets) Add(files, ComponentType.OptionSet, optionSet.Name, optionSet); + foreach (var form in exported.Forms) Add(files, ComponentType.SystemForm, form.FormId, form); + foreach (var view in exported.Views) Add(files, ComponentType.SavedQuery, view.SavedQueryId, view); + foreach (var webResource in exported.WebResources) Add(files, ComponentType.WebResource, webResource.WebResourceId, webResource); + foreach (var workflow in exported.Workflows) Add(files, ComponentType.Workflow, workflow.WorkflowId, workflow); + foreach (var pluginAssembly in exported.PluginAssemblies) Add(files, ComponentType.PluginAssembly, pluginAssembly.PluginAssemblyId, pluginAssembly); + foreach (var step in exported.SdkMessageProcessingSteps) Add(files, ComponentType.SdkMessageProcessingStep, step.SdkMessageProcessingStepId, step); + foreach (var role in exported.SecurityRoles) Add(files, ComponentType.Role, role.RoleId, role); + foreach (var appModule in exported.AppModules) Add(files, ComponentType.AppModule, appModule.UniqueName, appModule); + foreach (var siteMap in exported.SiteMaps) Add(files, ComponentType.SiteMap, siteMap.UniqueName, siteMap); + foreach (var ribbon in exported.Ribbons) Add(files, ComponentType.RibbonCustomization, ribbon.EntityLogicalName, ribbon); + + return files; + } + + private static void Add(Dictionary files, ComponentType type, string? identity, MetadataBase metadata) + { + if (identity is null || metadata.Source?.FilePath is not { } filePath) return; + + files[ComponentFileKey(type, identity)] = filePath; + } + + private static string ComponentFileKey(ComponentType type, string identity) => $"{(int)type}:{identity}"; + + private static void DeleteComponentFiles(string stagingRoot, ComponentType componentType, string filePath) + { + var fullStagingRoot = Path.GetFullPath(stagingRoot); + var fullFilePath = Path.GetFullPath(filePath); + if (!fullFilePath.StartsWith(fullStagingRoot, StringComparison.OrdinalIgnoreCase)) + return; + + if (componentType == ComponentType.Entity) + { + var entityDirectory = Path.GetDirectoryName(fullFilePath); + if (entityDirectory is not null && Directory.Exists(entityDirectory)) + Directory.Delete(entityDirectory, recursive: true); + return; + } + + DeleteFileIfExists(fullFilePath); + DeleteFileIfExists(fullFilePath.EndsWith(".data.xml", StringComparison.OrdinalIgnoreCase) + ? fullFilePath.Substring(0, fullFilePath.Length - ".data.xml".Length) + : fullFilePath + ".data.xml"); + PruneEmptyDirectories(Path.GetDirectoryName(fullFilePath), fullStagingRoot); + } + + private static void DeleteFileIfExists(string path) + { + if (File.Exists(path)) + File.Delete(path); + } + + private static void PruneEmptyDirectories(string? directory, string stagingRoot) + { + while (directory is not null + && !string.Equals(Path.GetFullPath(directory), stagingRoot, StringComparison.OrdinalIgnoreCase) + && Directory.Exists(directory) + && !Directory.EnumerateFileSystemEntries(directory).Any()) + { + Directory.Delete(directory); + directory = Path.GetDirectoryName(directory); + } + } +} diff --git a/src/TALXIS.CLI.Platform.Dataverse.Application/Pipeline/Steps/SolutionManifestNormalizationStep.cs b/src/TALXIS.CLI.Platform.Dataverse.Application/Pipeline/Steps/SolutionManifestNormalizationStep.cs deleted file mode 100644 index de0744a5..00000000 --- a/src/TALXIS.CLI.Platform.Dataverse.Application/Pipeline/Steps/SolutionManifestNormalizationStep.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System.Xml.Linq; - -namespace TALXIS.CLI.Platform.Dataverse.Application.Pipeline.Steps; - -internal sealed class SolutionManifestNormalizationStep : ISolutionPullStep -{ - public void Execute(SolutionPullContext context) - { - var stagingSolutionXml = Path.Combine( - context.StagingDirectory, - SolutionPullPipelineConstants.OtherDirectoryName, - "Solution.xml"); - if (!File.Exists(stagingSolutionXml)) - return; - - XDocument stagingDocument; - try - { - stagingDocument = XDocument.Load(stagingSolutionXml); - } - catch (System.Xml.XmlException) - { - return; - } - - var stagingManifest = SolutionPullPipelineXml.FindSolutionManifest(stagingDocument); - if (stagingManifest is null) - return; - - var namespaceName = stagingManifest.Name.Namespace; - var stagingVersion = stagingManifest.Element(namespaceName + "Version"); - var localVersion = SolutionPullPipelineXml.ReadSolutionManifestElementValue( - context.DestinationDirectory, - "Version"); - if (stagingVersion is not null && !string.IsNullOrWhiteSpace(localVersion)) - stagingVersion.Value = localVersion; - - var managedElement = stagingManifest.Element(namespaceName + "Managed"); - if (managedElement is null) - { - managedElement = new XElement(namespaceName + "Managed"); - stagingManifest.Add(managedElement); - } - - managedElement.Value = "2"; - stagingDocument.Save(stagingSolutionXml); - } -} diff --git a/src/TALXIS.CLI.Platform.Dataverse.Application/Pipeline/Steps/SystemRelationshipExclusionStep.cs b/src/TALXIS.CLI.Platform.Dataverse.Application/Pipeline/Steps/SystemRelationshipExclusionStep.cs deleted file mode 100644 index 48fa1ccb..00000000 --- a/src/TALXIS.CLI.Platform.Dataverse.Application/Pipeline/Steps/SystemRelationshipExclusionStep.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System.Text.RegularExpressions; -using System.Xml.Linq; - -namespace TALXIS.CLI.Platform.Dataverse.Application.Pipeline.Steps; - -internal sealed class SystemRelationshipExclusionStep : ISolutionPullStep -{ - private static readonly Regex StandardSystemRelationshipPattern = new( - "^(business_unit_.+|lk_.+_(createdby|modifiedby)|owner_.+|team_.+|user_.+)$", - RegexOptions.IgnoreCase); - - public void Execute(SolutionPullContext context) - { - var relationshipsXml = Path.Combine( - context.StagingDirectory, - SolutionPullPipelineConstants.OtherDirectoryName, - "Relationships.xml"); - if (!File.Exists(relationshipsXml)) - return; - - var fileContents = File.ReadAllText(relationshipsXml); - if (string.IsNullOrWhiteSpace(fileContents)) - return; - - XDocument relationshipsDocument; - try - { - relationshipsDocument = XDocument.Parse(fileContents); - } - catch (System.Xml.XmlException) - { - return; - } - - var removed = relationshipsDocument - .Descendants() - .Where(element => element.Name.LocalName == "EntityRelationship") - .Select(element => new - { - Element = element, - Name = element.Attribute("Name")?.Value - }) - .Where(item => !string.IsNullOrWhiteSpace(item.Name) && StandardSystemRelationshipPattern.IsMatch(item.Name)) - .ToList(); - - if (removed.Count == 0) - return; - - foreach (var relationship in removed) - { - relationship.Element.Remove(); - context.ExcludedRelationships.Add(relationship.Name!); - } - - relationshipsDocument.Save(relationshipsXml); - } -} diff --git a/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataverseSolutionPullService.cs b/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataverseSolutionPullService.cs index 2a565efa..12a91b5a 100644 --- a/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataverseSolutionPullService.cs +++ b/src/TALXIS.CLI.Platform.Dataverse.Application/Services/DataverseSolutionPullService.cs @@ -53,8 +53,7 @@ public async Task PullAsync( var steps = new ISolutionPullStep[] { - new SystemRelationshipExclusionStep(), - new SolutionManifestNormalizationStep(), + new ExportNormalizationStep(), new PluginAssemblyNormalizationStep(_logger), new ProjectReferenceBinaryExclusionStep(_projectReferenceReader), new ScriptLibraryExclusionStep(_projectReferenceReader, _logger), @@ -74,7 +73,8 @@ public async Task PullAsync( context.ExcludedBinaries, context.ExcludedWebResources, context.ExcludedPcfControls, - removed); + removed, + context.NormalizationChanges); } finally { diff --git a/src/TALXIS.CLI.Platform.Dataverse.Application/TALXIS.CLI.Platform.Dataverse.Application.csproj b/src/TALXIS.CLI.Platform.Dataverse.Application/TALXIS.CLI.Platform.Dataverse.Application.csproj index e22cdac6..b78dfb10 100644 --- a/src/TALXIS.CLI.Platform.Dataverse.Application/TALXIS.CLI.Platform.Dataverse.Application.csproj +++ b/src/TALXIS.CLI.Platform.Dataverse.Application/TALXIS.CLI.Platform.Dataverse.Application.csproj @@ -9,7 +9,8 @@ - + + diff --git a/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/SolutionPullPipelineTests.cs b/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/SolutionPullPipelineTests.cs index 19ee9feb..d3b26740 100644 --- a/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/SolutionPullPipelineTests.cs +++ b/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/SolutionPullPipelineTests.cs @@ -58,8 +58,7 @@ public void ReferencedPluginDllExcluded_NonReferencedKept() var steps = new ISolutionPullStep[] { - new SystemRelationshipExclusionStep(), - new SolutionManifestNormalizationStep(), + new ExportNormalizationStep(), new PluginAssemblyNormalizationStep(NullLogger.Instance), new ProjectReferenceBinaryExclusionStep(_projectReferenceReader), new ScriptLibraryExclusionStep(_projectReferenceReader, NullLogger.Instance), diff --git a/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/SolutionPullTransformTests.cs b/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/SolutionPullTransformTests.cs index b11c143d..392f8426 100644 --- a/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/SolutionPullTransformTests.cs +++ b/tests/TALXIS.CLI.Tests/Environment/Platforms/Dataverse/SolutionPullTransformTests.cs @@ -13,8 +13,7 @@ public class SolutionPullTransformTests : IDisposable private readonly PcfControlExclusionStep _pcfControlExclusionStep; private readonly string _root; private readonly ScriptLibraryExclusionStep _scriptLibraryExclusionStep; - private readonly SolutionManifestNormalizationStep _solutionManifestNormalizationStep; - private readonly SystemRelationshipExclusionStep _systemRelationshipExclusionStep; + private readonly ExportNormalizationStep _exportNormalizationStep; // A non-existent path simulates a first-sync scenario: no local convention established yet. // All assemblies are treated as new and default to the flat TALXIS SDK layout. @@ -30,8 +29,7 @@ public SolutionPullTransformTests() _pluginAssemblyNormalizationStep = new PluginAssemblyNormalizationStep(NullLogger.Instance); _pcfControlExclusionStep = new PcfControlExclusionStep(projectReferenceReader); _scriptLibraryExclusionStep = new ScriptLibraryExclusionStep(projectReferenceReader, NullLogger.Instance); - _solutionManifestNormalizationStep = new SolutionManifestNormalizationStep(); - _systemRelationshipExclusionStep = new SystemRelationshipExclusionStep(); + _exportNormalizationStep = new ExportNormalizationStep(); } public void Dispose() @@ -296,17 +294,19 @@ private void WriteWebResource(string name) File.WriteAllText(Path.Combine(dir, name + ".data.xml"), $"{name}"); } - private void WriteSolutionManifest(string root, string version, string managed) + private void WriteSolutionManifest(string root, string version, string managed, string rootAttributes = "", string rootComponents = "") { var otherDir = Path.Combine(root, "Other"); Directory.CreateDirectory(otherDir); File.WriteAllText( Path.Combine(otherDir, "Solution.xml"), $""" - + + TestSolution {version} {managed} + {rootComponents} """); @@ -356,25 +356,177 @@ public void ExcludeWebResource_NoOp_WhenNoWebResourcesFolder() } // ────────────────────────────────────────────────────────────────────────────── - // NormalizeSolutionManifest + // ExportNormalizationStep — manifest normalization // ────────────────────────────────────────────────────────────────────────────── + private string CreateDestination(string version = "1.0.0.0", string managed = "2") + { + var destinationRoot = Path.Combine(Path.GetTempPath(), "dest_" + Guid.NewGuid().ToString("N")); + WriteSolutionManifest(destinationRoot, version, managed); + return destinationRoot; + } + [Fact] - public void NormalizeSolutionManifest_PreservesLocalVersion_WhenLocalExists() + public void Normalize_PreservesLocalManifestAsSourceOfTruth() { WriteSolutionManifest(_root, "1.0.12606.28000", "0"); + var destinationRoot = CreateDestination(version: "1.0.0.42", managed: "2"); + + try + { + var context = CreateContext(destinationRoot); + _exportNormalizationStep.Execute(context); + + var stagingManifest = File.ReadAllText(Path.Combine(_root, "Other", "Solution.xml")); + var localManifest = File.ReadAllText(Path.Combine(destinationRoot, "Other", "Solution.xml")); + Assert.Equal(localManifest, stagingManifest); + Assert.Empty(context.NormalizationChanges); + } + finally + { + Directory.Delete(destinationRoot, recursive: true); + } + } + + [Fact] + public void Normalize_DeclaresPulledSubcomponentsOfIncludedEntity() + { + const string formId = "9c7e6ba6-1111-2222-3333-444444444444"; + const string viewId = "9c7e6ba6-5555-6666-7777-888888888888"; + WriteSolutionManifest(_root, "1.0.0.0", "2"); + var formDir = Path.Combine(_root, "Entities", "tprt_testitem", "FormXml", "main"); + Directory.CreateDirectory(formDir); + File.WriteAllText(Path.Combine(formDir, $"{{{formId}}}.xml"), + $$""" + + + {{{formId}}} + + + + """); + var viewDir = Path.Combine(_root, "Entities", "tprt_testitem", "SavedQueries"); + Directory.CreateDirectory(viewDir); + File.WriteAllText(Path.Combine(viewDir, $"{{{viewId}}}.xml"), + $$""" + + + {{{viewId}}} + 0 + + + + """); + var destinationRoot = CreateDestination(); + + try + { + var context = CreateContext(destinationRoot); + _exportNormalizationStep.Execute(context); + + var document = XDocument.Load(Path.Combine(_root, "Other", "Solution.xml")); + var formComponent = document.Descendants("RootComponent") + .SingleOrDefault(c => c.Attribute("type")?.Value == "60"); + Assert.NotNull(formComponent); + Assert.Contains(formId, formComponent!.Attribute("id")?.Value, StringComparison.OrdinalIgnoreCase); + Assert.Contains(context.NormalizationChanges, c => c.Contains("Declared form")); + + Assert.Empty(document.Descendants("RootComponent").Where(c => c.Attribute("type")?.Value == "26")); + Assert.DoesNotContain(context.NormalizationChanges, c => c.Contains("Declared view")); + + File.Copy(Path.Combine(_root, "Other", "Solution.xml"), Path.Combine(destinationRoot, "Other", "Solution.xml"), overwrite: true); + var secondContext = CreateContext(destinationRoot); + _exportNormalizationStep.Execute(secondContext); + + var secondDocument = XDocument.Load(Path.Combine(_root, "Other", "Solution.xml")); + Assert.Single(secondDocument.Descendants("RootComponent").Where(c => c.Attribute("type")?.Value == "60")); + Assert.DoesNotContain(secondContext.NormalizationChanges, c => c.Contains("Declared form")); + } + finally + { + Directory.Delete(destinationRoot, recursive: true); + } + } + + [Fact] + public void Normalize_RunsOnFreshScaffoldWithMismatchedUniqueName() + { + const string bootstrapFormId = "1a2b3c4d-0001-4002-8003-000000000042"; + WriteSolutionManifest(_root, "1.0.0.0", "0"); + WriteRelationshipsFile( + """ + + + + + """); + var bootstrapFormDir = Path.Combine(_root, "Entities", "tprt_testitem", "FormXml", "main"); + Directory.CreateDirectory(bootstrapFormDir); + File.WriteAllText(Path.Combine(bootstrapFormDir, $"{{{bootstrapFormId}}}.xml"), + $$""" + + + {{{bootstrapFormId}}} + + + + """); + var destinationRoot = Path.Combine(Path.GetTempPath(), "dest_" + Guid.NewGuid().ToString("N")); + var destinationOther = Path.Combine(destinationRoot, "Other"); + Directory.CreateDirectory(destinationOther); + File.WriteAllText(Path.Combine(destinationOther, "Solution.xml"), + """ + + + pulled + 1.0 + 2 + + + + """); + + try + { + var context = CreateContext(destinationRoot); + _exportNormalizationStep.Execute(context); + + var document = XDocument.Load(Path.Combine(_root, "Other", "Relationships.xml")); + var remaining = document.Descendants("EntityRelationship") + .Select(element => element.Attribute("Name")?.Value) + .ToArray(); + Assert.Equal(new[] { "tprt_testitem_tprt_custom" }, remaining); + Assert.Contains("owner_tprt_testitem", context.ExcludedRelationships); + + var manifest = XDocument.Load(Path.Combine(_root, "Other", "Solution.xml")); + var formComponent = manifest.Descendants("RootComponent") + .SingleOrDefault(c => c.Attribute("type")?.Value == "60"); + Assert.NotNull(formComponent); + Assert.Contains(bootstrapFormId, formComponent!.Attribute("id")?.Value, StringComparison.OrdinalIgnoreCase); + } + finally + { + Directory.Delete(destinationRoot, recursive: true); + } + } + + [Fact] + public void Normalize_KeepsLocalOnlyRootComponentAfterPull() + { + WriteSolutionManifest(_root, "1.0.0.0", "2"); var destinationRoot = Path.Combine(Path.GetTempPath(), "dest_" + Guid.NewGuid().ToString("N")); - WriteSolutionManifest(destinationRoot, "1.0.0.42", "1"); + WriteSolutionManifest(destinationRoot, "1.0.0.0", "2", + rootComponents: ""); try { var context = CreateContext(destinationRoot); - _solutionManifestNormalizationStep.Execute(context); + _exportNormalizationStep.Execute(context); var document = XDocument.Load(Path.Combine(_root, "Other", "Solution.xml")); - var manifest = document.Descendants("SolutionManifest").Single(); - Assert.Equal("1.0.0.42", manifest.Element("Version")?.Value); - Assert.Equal("2", manifest.Element("Managed")?.Value); + var components = document.Descendants("RootComponent").ToArray(); + Assert.Equal(2, components.Length); + Assert.Contains(components, c => c.Attribute("type")?.Value == "60"); } finally { @@ -383,119 +535,155 @@ public void NormalizeSolutionManifest_PreservesLocalVersion_WhenLocalExists() } [Fact] - public void NormalizeSolutionManifest_UsesDataverseVersion_WhenNoLocalFile() + public void Normalize_Skips_WhenNoLocalSolutionXml() { WriteSolutionManifest(_root, "1.0.12606.28000", "1"); - var context = CreateContext(Path.Combine(Path.GetTempPath(), "dest_" + Guid.NewGuid().ToString("N"))); - _solutionManifestNormalizationStep.Execute(context); + var context = CreateContext(NoLocalConvention); + _exportNormalizationStep.Execute(context); var document = XDocument.Load(Path.Combine(_root, "Other", "Solution.xml")); var manifest = document.Descendants("SolutionManifest").Single(); Assert.Equal("1.0.12606.28000", manifest.Element("Version")?.Value); - Assert.Equal("2", manifest.Element("Managed")?.Value); + Assert.Equal("1", manifest.Element("Managed")?.Value); + Assert.Empty(context.NormalizationChanges); } [Fact] - public void NormalizeSolutionManifest_ForcesManagedTwo_Regardless() + public void Normalize_StripsServerVersionAttributes() { - WriteSolutionManifest(_root, "1.0.0.0", "0"); + WriteSolutionManifest(_root, "1.0.0.0", "2", + rootAttributes: " OrganizationVersion=\"9.2.25092.135\" OrganizationSchemaType=\"Standard\" CRMServerServiceabilityVersion=\"9.2.25092.00139\""); + var destinationRoot = CreateDestination(); - var context = CreateContext(NoLocalConvention); - _solutionManifestNormalizationStep.Execute(context); + try + { + var context = CreateContext(destinationRoot); + _exportNormalizationStep.Execute(context); - var document = XDocument.Load(Path.Combine(_root, "Other", "Solution.xml")); - Assert.Equal("2", document.Descendants("Managed").Single().Value); + var root = XDocument.Load(Path.Combine(_root, "Other", "Solution.xml")).Root!; + Assert.Null(root.Attribute("OrganizationVersion")); + Assert.Null(root.Attribute("OrganizationSchemaType")); + Assert.Null(root.Attribute("CRMServerServiceabilityVersion")); + Assert.NotEmpty(context.NormalizationChanges); + } + finally + { + Directory.Delete(destinationRoot, recursive: true); + } } // ────────────────────────────────────────────────────────────────────────────── - // ExcludeStandardSystemRelationships + // ExportNormalizationStep — system relationship exclusion // ────────────────────────────────────────────────────────────────────────────── [Fact] - public void ExcludeStandardSystemRelationships_RemovesKnownPatterns() + public void Normalize_RemovesKnownSystemRelationshipPatterns() { + WriteSolutionManifest(_root, "1.0.0.0", "2"); WriteRelationshipsFile( """ - - - - - - - - - - - + + + + + + + + + """); + var destinationRoot = CreateDestination(); - var context = CreateContext(NoLocalConvention); - _systemRelationshipExclusionStep.Execute(context); - - var document = XDocument.Load(Path.Combine(_root, "Other", "Relationships.xml")); - var remaining = document.Descendants("EntityRelationship") - .Select(element => element.Attribute("Name")?.Value) - .Where(name => name is not null) - .ToArray(); - Assert.Equal(new[] { "tprt_testitem_tprt_custom" }, remaining); + try + { + var context = CreateContext(destinationRoot); + _exportNormalizationStep.Execute(context); + + var document = XDocument.Load(Path.Combine(_root, "Other", "Relationships.xml")); + var remaining = document.Descendants("EntityRelationship") + .Select(element => element.Attribute("Name")?.Value) + .Where(name => name is not null) + .ToArray(); + Assert.Equal(new[] { "tprt_testitem_tprt_custom" }, remaining); + Assert.Equal(6, context.ExcludedRelationships.Count); + } + finally + { + Directory.Delete(destinationRoot, recursive: true); + } } [Fact] - public void ExcludeStandardSystemRelationships_KeepsCustomRelationships() + public void Normalize_KeepsCustomAndLocallyPresentRelationships() { + WriteSolutionManifest(_root, "1.0.0.0", "2"); WriteRelationshipsFile( """ - - - - - - + + + + """); - - var context = CreateContext(NoLocalConvention); - _systemRelationshipExclusionStep.Execute(context); - - var document = XDocument.Load(Path.Combine(_root, "Other", "Relationships.xml")); - var remaining = document.Descendants("EntityRelationship") - .Select(element => element.Attribute("Name")?.Value) - .Where(name => name is not null) - .ToArray(); - Assert.Empty(context.ExcludedRelationships); - Assert.Equal(new[] { "tprt_testitem_tprt_custom", "custom_lookup_relationship" }, remaining); - } - - [Fact] - public void ExcludeStandardSystemRelationships_ReturnsRemovedNames() - { - WriteRelationshipsFile( + var destinationRoot = CreateDestination(); + var destinationOther = Path.Combine(destinationRoot, "Other"); + File.WriteAllText(Path.Combine(destinationOther, "Relationships.xml"), """ - - - - - - - + + + """); - var context = CreateContext(NoLocalConvention); - _systemRelationshipExclusionStep.Execute(context); - - Assert.Equal(new[] { "business_unit_tprt_testitem", "owner_tprt_testitem" }, context.ExcludedRelationships); + try + { + var context = CreateContext(destinationRoot); + _exportNormalizationStep.Execute(context); + + var document = XDocument.Load(Path.Combine(_root, "Other", "Relationships.xml")); + var remaining = document.Descendants("EntityRelationship") + .Select(element => element.Attribute("Name")?.Value) + .Where(name => name is not null) + .ToArray(); + Assert.Empty(context.ExcludedRelationships); + Assert.Equal(new[] { "tprt_testitem_tprt_custom", "owner_tprt_testitem" }, remaining); + } + finally + { + Directory.Delete(destinationRoot, recursive: true); + } } [Fact] - public void ExcludeStandardSystemRelationships_HandlesEmptyFile() + public void Normalize_RemovesEntityNotInLocalSolution() { - WriteRelationshipsFile(string.Empty); + WriteSolutionManifest(_root, "1.0.0.0", "2"); + var entityDir = Path.Combine(_root, "Entities", "tprt_leaked"); + Directory.CreateDirectory(entityDir); + File.WriteAllText(Path.Combine(entityDir, "Entity.xml"), + """ + + + + + + + + + """); + var destinationRoot = CreateDestination(); - var context = CreateContext(NoLocalConvention); - _systemRelationshipExclusionStep.Execute(context); + try + { + var context = CreateContext(destinationRoot); + _exportNormalizationStep.Execute(context); - Assert.Empty(context.ExcludedRelationships); - Assert.Equal(string.Empty, File.ReadAllText(Path.Combine(_root, "Other", "Relationships.xml"))); + Assert.False(Directory.Exists(entityDir)); + Assert.Contains(context.NormalizationChanges, c => c.Contains("tprt_leaked")); + } + finally + { + Directory.Delete(destinationRoot, recursive: true); + } } // ──────────────────────────────────────────────────────────────────────────────