Skip to content
Open
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
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ public sealed record SolutionPullResult(
IReadOnlyList<string> ExcludedBinaries,
IReadOnlyList<string> ExcludedWebResources,
IReadOnlyList<string> ExcludedPcfControls,
IReadOnlyList<string> RemovedFiles);
IReadOnlyList<string> RemovedFiles,
IReadOnlyList<string> NormalizationChanges);

public interface ISolutionPullService
{
Expand Down
2 changes: 1 addition & 1 deletion src/TALXIS.CLI.Core/TALXIS.CLI.Core.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

<ItemGroup>
<PackageReference Include="DotMake.CommandLine" Version="2.6.7" />
<PackageReference Include="TALXIS.Platform.Metadata" Version="0.8.0" />
<PackageReference Include="TALXIS.Platform.Metadata" Version="0.*" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.5" />
<PackageReference Include="Microsoft.Identity.Client" Version="4.83.3" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ protected override async Task<int> ExecuteAsync()
excludedWebResources = result.ExcludedWebResources,
excludedPcfControls = result.ExcludedPcfControls,
removedFiles = result.RemovedFiles,
normalizationChanges = result.NormalizationChanges,
},
_ =>
{
Expand All @@ -145,6 +146,8 @@ protected override async Task<int> 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
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ protected override async Task<int> ExecuteAsync()
excludedWebResources = result.ExcludedWebResources,
excludedPcfControls = result.ExcludedPcfControls,
removedFiles = result.RemovedFiles,
normalizationChanges = result.NormalizationChanges,
};

OutputFormatter.WriteData(payload, _ =>
Expand All @@ -61,6 +62,7 @@ protected override async Task<int> 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<string> items)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@

<ItemGroup>
<PackageReference Include="DotMake.CommandLine" Version="2.6.7" />
<PackageReference Include="TALXIS.Platform.Metadata.Serialization.Xml" Version="0.8.0" />
<PackageReference Include="TALXIS.Platform.Metadata.Packaging" Version="0.8.0" />
<PackageReference Include="TALXIS.Platform.Metadata.Serialization.Xml" Version="0.*" />
<PackageReference Include="TALXIS.Platform.Metadata.Packaging" Version="0.*" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@
<PackageReference Include="Microsoft.TemplateEngine.Edge" Version="10.0.201" />
<PackageReference Include="Microsoft.TemplateEngine.Orchestrator.RunnableProjects" Version="10.0.201" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.5" />
<PackageReference Include="TALXIS.Platform.Metadata.Validation" Version="0.8.0" />
<PackageReference Include="TALXIS.Platform.Metadata.Serialization.Xml" Version="0.8.0" />
<PackageReference Include="TALXIS.Platform.Metadata.Validation" Version="0.*" />
<PackageReference Include="TALXIS.Platform.Metadata.Serialization.Xml" Version="0.*" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ internal sealed class SolutionPullContext
public IReadOnlyCollection<string>? ReferencedPcfControlNames { get; init; }
public List<string> NormalizedAssemblies { get; } = [];
public List<string> ExcludedRelationships { get; } = [];
public List<string> NormalizationChanges { get; } = [];
public List<string> ExcludedBinaries { get; } = [];
public List<string> ExcludedWebResources { get; } = [];
public List<string> ExcludedPcfControls { get; } = [];
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Normalizes the unpacked export against the local source project via
/// <see cref="ExportNormalizer"/>: 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).
/// </summary>
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<string>(
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<string, string> CaptureComponentFiles(Workspace exported)
{
var files = new Dictionary<string, string>(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<string, string> 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);
}
}
}

This file was deleted.

Loading