diff --git a/ILRepack.IntegrationTests/MultiRepackIntegrationTests.cs b/ILRepack.IntegrationTests/MultiRepackIntegrationTests.cs new file mode 100644 index 00000000..e1b53e05 --- /dev/null +++ b/ILRepack.IntegrationTests/MultiRepackIntegrationTests.cs @@ -0,0 +1,455 @@ +// +// Copyright (c) 2026 Unity Technologies +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using ILRepacking; +using Mono.Cecil; +using NUnit.Framework; + +namespace ILRepack.IntegrationTests +{ + [TestFixture] + public class MultiRepackIntegrationTests + { + private string _tempDir; + private string _outputDir; + + [SetUp] + public void Setup() + { + _tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + _outputDir = Path.Combine(_tempDir, "output"); + Directory.CreateDirectory(_tempDir); + Directory.CreateDirectory(_outputDir); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_tempDir)) + { + try + { + Directory.Delete(_tempDir, true); + } + catch + { + // Best effort cleanup + } + } + } + + [Test] + public void EndToEnd_TwoGroups_WithDependency_Success() + { + // Arrange - Create test assemblies + var libAPath = CreateLibraryAssembly("LibraryA", _tempDir, new[] { "ClassA1", "ClassA2" }); + var libBPath = CreateLibraryAssembly("LibraryB", _tempDir, new[] { "ClassB1" }, new[] { "LibraryA" }); + + var configPath = Path.Combine(_tempDir, "config.json"); + var config = new MultiRepackConfiguration + { + Groups = new List + { + new AssemblyGroup + { + Name = "GroupA", + InputAssemblies = new List { libAPath }, + OutputAssembly = Path.Combine(_outputDir, "MergedA.dll") + }, + new AssemblyGroup + { + Name = "GroupB", + InputAssemblies = new List { libBPath }, + OutputAssembly = Path.Combine(_outputDir, "MergedB.dll") + } + }, + GlobalOptions = new GlobalRepackOptions + { + SearchDirectories = new List { _tempDir, _outputDir } + } + }; + config.SaveToFile(configPath); + + // Act + var options = new RepackOptions(new[] { $"/config:{configPath}" }); + var logger = new RepackLogger(options); + + var configObj = MultiRepackConfiguration.LoadFromFile(configPath); + using (var orchestrator = new MultiRepackOrchestrator(configObj, logger)) + { + orchestrator.Repack(); + } + + // Assert + Assert.That(File.Exists(Path.Combine(_outputDir, "MergedA.dll")), "MergedA.dll should exist"); + Assert.That(File.Exists(Path.Combine(_outputDir, "MergedB.dll")), "MergedB.dll should exist"); + + // Verify that MergedB references MergedA + using (var mergedB = AssemblyDefinition.ReadAssembly(Path.Combine(_outputDir, "MergedB.dll"))) + { + var references = mergedB.MainModule.AssemblyReferences; + + var hasMergedAReference = references.Any(r => r.Name == "MergedA"); + Assert.That(hasMergedAReference, "MergedB should reference MergedA"); + + var hasLibraryAReference = references.Any(r => r.Name == "LibraryA"); + Assert.That(hasLibraryAReference, Is.False, "MergedB should not reference LibraryA (should be rewritten to MergedA)"); + } + } + + [Test] + public void EndToEnd_ThreeGroups_ChainedDependencies_Success() + { + // Arrange - Create: C -> B -> A + var libAPath = CreateLibraryAssembly("LibA", _tempDir, new[] { "ClassA" }); + var libBPath = CreateLibraryAssembly("LibB", _tempDir, new[] { "ClassB" }, new[] { "LibA" }); + var libCPath = CreateLibraryAssembly("LibC", _tempDir, new[] { "ClassC" }, new[] { "LibB" }); + + var configPath = Path.Combine(_tempDir, "config.json"); + var config = new MultiRepackConfiguration + { + Groups = new List + { + new AssemblyGroup + { + Name = "GroupA", + InputAssemblies = new List { libAPath }, + OutputAssembly = Path.Combine(_outputDir, "OutA.dll") + }, + new AssemblyGroup + { + Name = "GroupB", + InputAssemblies = new List { libBPath }, + OutputAssembly = Path.Combine(_outputDir, "OutB.dll") + }, + new AssemblyGroup + { + Name = "GroupC", + InputAssemblies = new List { libCPath }, + OutputAssembly = Path.Combine(_outputDir, "OutC.dll") + } + }, + GlobalOptions = new GlobalRepackOptions + { + SearchDirectories = new List { _tempDir, _outputDir } + } + }; + config.SaveToFile(configPath); + + // Act + var options = new RepackOptions(new[] { $"/config:{configPath}" }); + var logger = new RepackLogger(options); + + var configObj = MultiRepackConfiguration.LoadFromFile(configPath); + using (var orchestrator = new MultiRepackOrchestrator(configObj, logger)) + { + orchestrator.Repack(); + } + + // Assert + Assert.That(File.Exists(Path.Combine(_outputDir, "OutA.dll"))); + Assert.That(File.Exists(Path.Combine(_outputDir, "OutB.dll"))); + Assert.That(File.Exists(Path.Combine(_outputDir, "OutC.dll"))); + + // Verify reference chain + using (var outB = AssemblyDefinition.ReadAssembly(Path.Combine(_outputDir, "OutB.dll"))) + { + var hasOutAReference = outB.MainModule.AssemblyReferences.Any(r => r.Name == "OutA"); + Assert.That(hasOutAReference, "OutB should reference OutA"); + } + + using (var outC = AssemblyDefinition.ReadAssembly(Path.Combine(_outputDir, "OutC.dll"))) + { + var hasOutBReference = outC.MainModule.AssemblyReferences.Any(r => r.Name == "OutB"); + Assert.That(hasOutBReference, "OutC should reference OutB"); + } + } + + [Test] + public void EndToEnd_MultipleAssembliesInGroup_Success() + { + // Arrange - Create multiple assemblies for each group + var libA1Path = CreateLibraryAssembly("LibA1", _tempDir, new[] { "ClassA1" }); + var libA2Path = CreateLibraryAssembly("LibA2", _tempDir, new[] { "ClassA2" }); + var libB1Path = CreateLibraryAssembly("LibB1", _tempDir, new[] { "ClassB1" }, new[] { "LibA1" }); + var libB2Path = CreateLibraryAssembly("LibB2", _tempDir, new[] { "ClassB2" }, new[] { "LibA2" }); + + var configPath = Path.Combine(_tempDir, "config.json"); + var config = new MultiRepackConfiguration + { + Groups = new List + { + new AssemblyGroup + { + Name = "GroupA", + InputAssemblies = new List { libA1Path, libA2Path }, + OutputAssembly = Path.Combine(_outputDir, "CombinedA.dll") + }, + new AssemblyGroup + { + Name = "GroupB", + InputAssemblies = new List { libB1Path, libB2Path }, + OutputAssembly = Path.Combine(_outputDir, "CombinedB.dll") + } + }, + GlobalOptions = new GlobalRepackOptions + { + SearchDirectories = new List { _tempDir, _outputDir } + } + }; + config.SaveToFile(configPath); + + // Act + var options = new RepackOptions(new[] { $"/config:{configPath}" }); + var logger = new RepackLogger(options); + + var configObj = MultiRepackConfiguration.LoadFromFile(configPath); + using (var orchestrator = new MultiRepackOrchestrator(configObj, logger)) + { + orchestrator.Repack(); + } + + // Assert + Assert.That(File.Exists(Path.Combine(_outputDir, "CombinedA.dll"))); + Assert.That(File.Exists(Path.Combine(_outputDir, "CombinedB.dll"))); + + // Verify that CombinedA contains types from both LibA1 and LibA2 + using (var combinedA = AssemblyDefinition.ReadAssembly(Path.Combine(_outputDir, "CombinedA.dll"))) + { + var types = combinedA.MainModule.Types.Select(t => t.Name).ToList(); + Assert.That(types.Contains("ClassA1"), "CombinedA should contain ClassA1"); + Assert.That(types.Contains("ClassA2"), "CombinedA should contain ClassA2"); + } + + // Verify that CombinedB contains types from both LibB1 and LibB2 + using (var combinedB = AssemblyDefinition.ReadAssembly(Path.Combine(_outputDir, "CombinedB.dll"))) + { + var types = combinedB.MainModule.Types.Select(t => t.Name).ToList(); + Assert.That(types.Contains("ClassB1"), "CombinedB should contain ClassB1"); + Assert.That(types.Contains("ClassB2"), "CombinedB should contain ClassB2"); + + // Verify references are rewritten + var hasLibA1Ref = combinedB.MainModule.AssemblyReferences.Any(r => r.Name == "LibA1"); + var hasLibA2Ref = combinedB.MainModule.AssemblyReferences.Any(r => r.Name == "LibA2"); + var hasCombinedARef = combinedB.MainModule.AssemblyReferences.Any(r => r.Name == "CombinedA"); + + Assert.That(hasLibA1Ref, Is.False, "CombinedB should not reference LibA1"); + Assert.That(hasLibA2Ref, Is.False, "CombinedB should not reference LibA2"); + Assert.That(hasCombinedARef, "CombinedB should reference CombinedA"); + } + } + + [Test] + public void EndToEnd_IndependentGroups_Success() + { + // Arrange - Create two independent groups with no cross-references + var libAPath = CreateLibraryAssembly("IndepA", _tempDir, new[] { "ClassA" }); + var libBPath = CreateLibraryAssembly("IndepB", _tempDir, new[] { "ClassB" }); + + var configPath = Path.Combine(_tempDir, "config.json"); + var config = new MultiRepackConfiguration + { + Groups = new List + { + new AssemblyGroup + { + Name = "Independent1", + InputAssemblies = new List { libAPath }, + OutputAssembly = Path.Combine(_outputDir, "Indep1.dll") + }, + new AssemblyGroup + { + Name = "Independent2", + InputAssemblies = new List { libBPath }, + OutputAssembly = Path.Combine(_outputDir, "Indep2.dll") + } + }, + GlobalOptions = new GlobalRepackOptions + { + SearchDirectories = new List { _tempDir, _outputDir } + } + }; + config.SaveToFile(configPath); + + // Act + var options = new RepackOptions(new[] { $"/config:{configPath}" }); + var logger = new RepackLogger(options); + + var configObj = MultiRepackConfiguration.LoadFromFile(configPath); + using (var orchestrator = new MultiRepackOrchestrator(configObj, logger)) + { + orchestrator.Repack(); + } + + // Assert + Assert.That(File.Exists(Path.Combine(_outputDir, "Indep1.dll"))); + Assert.That(File.Exists(Path.Combine(_outputDir, "Indep2.dll"))); + } + + [Test] + public void EndToEnd_MultipleReferencesToMergedAssemblies_NoDuplicates() + { + // Arrange - Create scenario where B references both A1 and A2, which get merged + // This tests that duplicate references are removed after rewriting + var libA1Path = CreateLibraryAssembly("LibraryA1", _tempDir, new[] { "ClassA1" }); + var libA2Path = CreateLibraryAssembly("LibraryA2", _tempDir, new[] { "ClassA2" }); + var libBPath = CreateLibraryAssembly("LibraryB", _tempDir, new[] { "ClassB" }, new[] { "LibraryA1", "LibraryA2" }); + + var configPath = Path.Combine(_tempDir, "config.json"); + var config = new MultiRepackConfiguration + { + Groups = new List + { + new AssemblyGroup + { + Name = "GroupA", + InputAssemblies = new List { libA1Path, libA2Path }, + OutputAssembly = Path.Combine(_outputDir, "MergedA.dll") + }, + new AssemblyGroup + { + Name = "GroupB", + InputAssemblies = new List { libBPath }, + OutputAssembly = Path.Combine(_outputDir, "MergedB.dll") + } + }, + GlobalOptions = new GlobalRepackOptions + { + SearchDirectories = new List { _tempDir, _outputDir } + } + }; + config.SaveToFile(configPath); + + // Act + var options = new RepackOptions(new[] { $"/config:{configPath}" }); + var logger = new RepackLogger(options); + + var configObj = MultiRepackConfiguration.LoadFromFile(configPath); + using (var orchestrator = new MultiRepackOrchestrator(configObj, logger)) + { + orchestrator.Repack(); + } + + // Assert - Verify both outputs exist + var mergedAPath = Path.Combine(_outputDir, "MergedA.dll"); + var mergedBPath = Path.Combine(_outputDir, "MergedB.dll"); + Assert.That(File.Exists(mergedAPath), "MergedA.dll should exist"); + Assert.That(File.Exists(mergedBPath), "MergedB.dll should exist"); + + // Verify MergedB references MergedA only once (no duplicates) + using (var mergedB = AssemblyDefinition.ReadAssembly(mergedBPath)) + { + var references = mergedB.MainModule.AssemblyReferences + .Where(r => r.Name == "MergedA") + .ToList(); + + var allReferences = string.Join(", ", mergedB.MainModule.AssemblyReferences.Select(r => r.Name)); + Assert.That(references.Count, Is.EqualTo(1), + $"MergedB should have exactly 1 reference to MergedA, but found {references.Count}. " + + $"All references: {allReferences}"); + } + } + + // Helper method to create a library assembly with Cecil + private string CreateLibraryAssembly(string name, string outputDir, string[] classNames, string[] references = null) + { + var assemblyName = new Mono.Cecil.AssemblyNameDefinition(name, new Version(1, 0, 0, 0)); + + // Create a temporary assembly first + var tempAssembly = Mono.Cecil.AssemblyDefinition.CreateAssembly( + assemblyName, + name, + Mono.Cecil.ModuleKind.Dll); + + // Add references if specified + if (references != null) + { + foreach (var refName in references) + { + var reference = new Mono.Cecil.AssemblyNameReference(refName, new Version(1, 0, 0, 0)); + tempAssembly.MainModule.AssemblyReferences.Add(reference); + } + } + + // Add classes + foreach (var className in classNames) + { + var type = new Mono.Cecil.TypeDefinition( + name, + className, + Mono.Cecil.TypeAttributes.Public | Mono.Cecil.TypeAttributes.Class, + tempAssembly.MainModule.TypeSystem.Object); + + // Add a simple method + var method = new Mono.Cecil.MethodDefinition( + "GetValue", + Mono.Cecil.MethodAttributes.Public, + tempAssembly.MainModule.TypeSystem.Int32); + + var il = method.Body.GetILProcessor(); + il.Emit(Mono.Cecil.Cil.OpCodes.Ldc_I4, 42); + il.Emit(Mono.Cecil.Cil.OpCodes.Ret); + + type.Methods.Add(method); + tempAssembly.MainModule.Types.Add(type); + } + + var outputPath = Path.Combine(outputDir, $"{name}.dll"); + + // Write and dispose the temp assembly + tempAssembly.Write(outputPath); + tempAssembly.Dispose(); + + // Now reload it and fix the corlib reference to use .NET Core + var assembly = Mono.Cecil.AssemblyDefinition.ReadAssembly(outputPath); + + // Replace mscorlib with System.Private.CoreLib + var mscorlibRef = assembly.MainModule.AssemblyReferences.FirstOrDefault(r => r.Name == "mscorlib"); + if (mscorlibRef != null) + { + // Remove mscorlib + assembly.MainModule.AssemblyReferences.Remove(mscorlibRef); + + // Add System.Private.CoreLib (the .NET Core corlib) + var coreLibRef = new Mono.Cecil.AssemblyNameReference("System.Private.CoreLib", new Version(8, 0, 0, 0)) + { + PublicKeyToken = new byte[] { 0x7c, 0xec, 0x85, 0xd7, 0xbe, 0xa7, 0x79, 0x8e } + }; + assembly.MainModule.AssemblyReferences.Insert(0, coreLibRef); + + // Update all type references that point to mscorlib + foreach (var typeRef in assembly.MainModule.GetTypeReferences()) + { + if (typeRef.Scope == mscorlibRef) + { + typeRef.Scope = coreLibRef; + } + } + } + + // Save the modified assembly + assembly.Write(outputPath); + assembly.Dispose(); + + return outputPath; + } + } +} + diff --git a/ILRepack.Tests/MultiRepackConfigurationTests.cs b/ILRepack.Tests/MultiRepackConfigurationTests.cs new file mode 100644 index 00000000..5b0ef6bd --- /dev/null +++ b/ILRepack.Tests/MultiRepackConfigurationTests.cs @@ -0,0 +1,371 @@ +// +// Copyright (c) 2026 Unity Technologies +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Generic; +using System.IO; +using ILRepacking; +using NUnit.Framework; + +namespace ILRepack.Tests +{ + [TestFixture] + public class MultiRepackConfigurationTests + { + private string _tempDir; + + [SetUp] + public void Setup() + { + _tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(_tempDir); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_tempDir)) + { + try + { + Directory.Delete(_tempDir, true); + } + catch + { + // Best effort cleanup + } + } + } + + [Test] + public void LoadFromFile_ValidConfiguration_Success() + { + // Arrange + var configPath = Path.Combine(_tempDir, "config.json"); + var json = @"{ + ""groups"": [ + { + ""name"": ""GroupA"", + ""inputAssemblies"": [""A1.dll"", ""A2.dll""], + ""outputAssembly"": ""A.dll"" + }, + { + ""name"": ""GroupB"", + ""inputAssemblies"": [""B1.dll"", ""B2.dll""], + ""outputAssembly"": ""B.dll"" + } + ], + ""globalOptions"": { + ""internalize"": true, + ""debugInfo"": false + } +}"; + File.WriteAllText(configPath, json); + + // Act + var config = MultiRepackConfiguration.LoadFromFile(configPath); + + // Assert + Assert.IsNotNull(config); + Assert.AreEqual(2, config.Groups.Count); + Assert.AreEqual("GroupA", config.Groups[0].Name); + Assert.AreEqual(2, config.Groups[0].InputAssemblies.Count); + Assert.AreEqual("A.dll", config.Groups[0].OutputAssembly); + Assert.IsTrue(config.GlobalOptions.Internalize.Value); + Assert.IsFalse(config.GlobalOptions.DebugInfo.Value); + } + + [Test] + public void LoadFromFile_FileNotFound_ThrowsException() + { + // Arrange + var configPath = Path.Combine(_tempDir, "nonexistent.json"); + + // Act & Assert + Assert.Throws(() => + MultiRepackConfiguration.LoadFromFile(configPath)); + } + + [Test] + public void LoadFromFile_InvalidJson_ThrowsException() + { + // Arrange + var configPath = Path.Combine(_tempDir, "invalid.json"); + File.WriteAllText(configPath, "{ invalid json }"); + + // Act & Assert + Assert.Throws(() => + MultiRepackConfiguration.LoadFromFile(configPath)); + } + + [Test] + public void Validate_EmptyGroups_ThrowsException() + { + // Arrange + var config = new MultiRepackConfiguration + { + Groups = new List() + }; + + // Act & Assert + var ex = Assert.Throws(() => config.Validate()); + Assert.That(ex.Message, Does.Contain("at least one assembly group")); + } + + [Test] + public void Validate_DuplicateOutputAssembly_ThrowsException() + { + // Arrange + var config = new MultiRepackConfiguration + { + Groups = new List + { + new AssemblyGroup + { + InputAssemblies = new List { "A1.dll" }, + OutputAssembly = "Output.dll" + }, + new AssemblyGroup + { + InputAssemblies = new List { "B1.dll" }, + OutputAssembly = "Output.dll" + } + } + }; + + // Act & Assert + var ex = Assert.Throws(() => config.Validate()); + Assert.That(ex.Message, Does.Contain("Duplicate output assembly")); + } + + [Test] + public void Validate_GroupWithoutInputAssemblies_ThrowsException() + { + // Arrange + var config = new MultiRepackConfiguration + { + Groups = new List + { + new AssemblyGroup + { + OutputAssembly = "Output.dll" + } + } + }; + + // Act & Assert + var ex = Assert.Throws(() => config.Validate()); + Assert.That(ex.Message, Does.Contain("at least one input assembly")); + } + + [Test] + public void Validate_GroupWithoutOutputAssembly_ThrowsException() + { + // Arrange + var config = new MultiRepackConfiguration + { + Groups = new List + { + new AssemblyGroup + { + InputAssemblies = new List { "A1.dll" } + } + } + }; + + // Act & Assert + var ex = Assert.Throws(() => config.Validate()); + Assert.That(ex.Message, Does.Contain("must specify an output assembly")); + } + + [Test] + public void SaveToFile_ValidConfiguration_Success() + { + // Arrange + var configPath = Path.Combine(_tempDir, "saved_config.json"); + var config = new MultiRepackConfiguration + { + Groups = new List + { + new AssemblyGroup + { + Name = "TestGroup", + InputAssemblies = new List { "A1.dll", "A2.dll" }, + OutputAssembly = "A.dll" + } + }, + GlobalOptions = new GlobalRepackOptions + { + Internalize = true, + DebugInfo = false + } + }; + + // Act + config.SaveToFile(configPath); + + // Assert + Assert.IsTrue(File.Exists(configPath)); + var loadedConfig = MultiRepackConfiguration.LoadFromFile(configPath); + Assert.AreEqual(1, loadedConfig.Groups.Count); + Assert.AreEqual("TestGroup", loadedConfig.Groups[0].Name); + } + + [Test] + public void RepackOptions_MultiRepackMode_IsMultiRepackReturnsTrue() + { + // Arrange + var options = new RepackOptions(new[] { "/config:test.json" }); + + // Act & Assert + Assert.IsTrue(options.IsMultiRepack); + Assert.AreEqual("test.json", options.MultiRepackConfigFile); + } + + [Test] + public void RepackOptions_MultiRepackMode_Validate_WithOutputFile_ThrowsException() + { + // Arrange + var options = new RepackOptions(new[] { "/config:test.json", "/out:output.dll" }); + + // Act & Assert + var ex = Assert.Throws(() => options.Validate()); + Assert.That(ex.Message, Does.Contain("cannot be used with 'config'")); + } + + [Test] + public void RepackOptions_MultiRepackMode_Validate_WithInputAssemblies_ThrowsException() + { + // Arrange + var options = new RepackOptions(new[] { "/config:test.json", "input.dll" }); + + // Act & Assert + var ex = Assert.Throws(() => options.Validate()); + Assert.That(ex.Message, Does.Contain("cannot be specified directly with 'config'")); + } + + [Test] + public void LoadFromFile_WithComments_Success() + { + // Arrange + var configPath = Path.Combine(_tempDir, "config_with_comments.json"); + var json = @"{ + // This is a comment + ""groups"": [ + { + ""name"": ""GroupA"", + ""inputAssemblies"": [""A1.dll""], // Another comment + ""outputAssembly"": ""A.dll"" + } + ] +}"; + File.WriteAllText(configPath, json); + + // Act + var config = MultiRepackConfiguration.LoadFromFile(configPath); + + // Assert + Assert.IsNotNull(config); + Assert.AreEqual(1, config.Groups.Count); + } + + [Test] + public void LoadFromFile_WithTrailingCommas_Success() + { + // Arrange + var configPath = Path.Combine(_tempDir, "config_with_trailing_commas.json"); + var json = @"{ + ""groups"": [ + { + ""name"": ""GroupA"", + ""inputAssemblies"": [""A1.dll"", ""A2.dll"",], + ""outputAssembly"": ""A.dll"", + }, + ], +}"; + File.WriteAllText(configPath, json); + + // Act + var config = MultiRepackConfiguration.LoadFromFile(configPath); + + // Assert + Assert.IsNotNull(config); + Assert.AreEqual(1, config.Groups.Count); + } + + [Test] + public void LoadFromFile_GroupSpecificOptions_OverrideGlobal() + { + // Arrange + var configPath = Path.Combine(_tempDir, "config_with_overrides.json"); + var json = @"{ + ""groups"": [ + { + ""name"": ""GroupA"", + ""inputAssemblies"": [""A1.dll""], + ""outputAssembly"": ""A.dll"", + ""options"": { + ""internalize"": false, + ""version"": ""1.0.0.0"" + } + } + ], + ""globalOptions"": { + ""internalize"": true + } +}"; + File.WriteAllText(configPath, json); + + // Act + var config = MultiRepackConfiguration.LoadFromFile(configPath); + + // Assert + Assert.IsNotNull(config); + Assert.IsTrue(config.GlobalOptions.Internalize.Value); + Assert.IsFalse(config.Groups[0].Options.Internalize.Value); + Assert.AreEqual("1.0.0.0", config.Groups[0].Options.Version); + } + + [Test] + public void LoadFromFile_CaseInsensitive_Success() + { + // Arrange + var configPath = Path.Combine(_tempDir, "config_case_insensitive.json"); + var json = @"{ + ""Groups"": [ + { + ""Name"": ""GroupA"", + ""InputAssemblies"": [""A1.dll""], + ""OutputAssembly"": ""A.dll"" + } + ], + ""GlobalOptions"": { + ""Internalize"": true + } +}"; + File.WriteAllText(configPath, json); + + // Act + var config = MultiRepackConfiguration.LoadFromFile(configPath); + + // Assert + Assert.IsNotNull(config); + Assert.AreEqual(1, config.Groups.Count); + Assert.AreEqual("GroupA", config.Groups[0].Name); + } + } +} + diff --git a/ILRepack.Tests/MultiRepackOrchestratorTests.cs b/ILRepack.Tests/MultiRepackOrchestratorTests.cs new file mode 100644 index 00000000..c0c4260c --- /dev/null +++ b/ILRepack.Tests/MultiRepackOrchestratorTests.cs @@ -0,0 +1,366 @@ +// +// Copyright (c) 2026 Unity Technologies +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Reflection.Emit; +using ILRepacking; +using NUnit.Framework; + +namespace ILRepack.Tests +{ + [TestFixture] + public class MultiRepackOrchestratorTests + { + private string _tempDir; + private TestLogger _logger; + + [SetUp] + public void Setup() + { + _tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(_tempDir); + _logger = new TestLogger(); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_tempDir)) + { + try + { + Directory.Delete(_tempDir, true); + } + catch + { + // Best effort cleanup + } + } + } + + [Test] + public void CircularDependency_ThrowsException() + { + // Arrange + var a1Path = CreateAssembly("A1", _tempDir); + var b1Path = CreateAssembly("B1", _tempDir); + + // Create A2 that references B1 + var a2Path = CreateAssemblyWithReference("A2", _tempDir, "B1"); + + // Create B2 that references A1 + var b2Path = CreateAssemblyWithReference("B2", _tempDir, "A1"); + + var config = new MultiRepackConfiguration + { + Groups = new List + { + new AssemblyGroup + { + Name = "GroupA", + InputAssemblies = new List { a1Path, a2Path }, + OutputAssembly = Path.Combine(_tempDir, "A.dll") + }, + new AssemblyGroup + { + Name = "GroupB", + InputAssemblies = new List { b1Path, b2Path }, + OutputAssembly = Path.Combine(_tempDir, "B.dll") + } + } + }; + + // Act & Assert + using (var orchestrator = new MultiRepackOrchestrator(config, _logger)) + { + var ex = Assert.Throws(() => orchestrator.Repack()); + Assert.That(ex.Message, Does.Contain("Circular dependency")); + } + } + + [Test] + public void LinearDependencies_CorrectOrder() + { + // Arrange - Create assemblies where B1 depends on A1 + var a1Path = CreateAssembly("A1", _tempDir); + var a2Path = CreateAssembly("A2", _tempDir); + var b1Path = CreateAssemblyWithReference("B1", _tempDir, "A1"); + var b2Path = CreateAssembly("B2", _tempDir); + + var groupA = new AssemblyGroup + { + Name = "GroupA", + InputAssemblies = new List { a1Path, a2Path }, + OutputAssembly = Path.Combine(_tempDir, "A.dll") + }; + + var groupB = new AssemblyGroup + { + Name = "GroupB", + InputAssemblies = new List { b1Path, b2Path }, + OutputAssembly = Path.Combine(_tempDir, "B.dll") + }; + + var config = new MultiRepackConfiguration + { + Groups = new List { groupB, groupA }, // Intentionally wrong order + GlobalOptions = new GlobalRepackOptions + { + SearchDirectories = new List { _tempDir } + } + }; + + // Act - Determine processing order + using (var orchestrator = new MultiRepackOrchestrator(config, _logger)) + { + var processingOrder = orchestrator.DetermineProcessingOrder(); + + // Assert - GroupA should be processed before GroupB (despite being listed second) + Assert.AreEqual(2, processingOrder.Count); + Assert.AreEqual("GroupA", processingOrder[0].Name, "GroupA should be first (no dependencies)"); + Assert.AreEqual("GroupB", processingOrder[1].Name, "GroupB should be second (depends on GroupA)"); + } + } + + [Test] + public void DuplicateAssemblyInGroups_ThrowsException() + { + // Arrange + var a1Path = CreateAssembly("A1", _tempDir); + + var config = new MultiRepackConfiguration + { + Groups = new List + { + new AssemblyGroup + { + Name = "GroupA", + InputAssemblies = new List { a1Path }, + OutputAssembly = Path.Combine(_tempDir, "A.dll") + }, + new AssemblyGroup + { + Name = "GroupB", + InputAssemblies = new List { a1Path }, + OutputAssembly = Path.Combine(_tempDir, "B.dll") + } + } + }; + + // Act & Assert + using (var orchestrator = new MultiRepackOrchestrator(config, _logger)) + { + var ex = Assert.Throws(() => orchestrator.Repack()); + Assert.That(ex.Message, Does.Contain("appears in multiple groups")); + } + } + + [Test] + public void MultipleIndependentGroups_AnyOrderValid() + { + // Arrange - Create two independent groups with no cross-references + var a1Path = CreateAssembly("A1", _tempDir); + var a2Path = CreateAssembly("A2", _tempDir); + var b1Path = CreateAssembly("B1", _tempDir); + var b2Path = CreateAssembly("B2", _tempDir); + + var groupA = new AssemblyGroup + { + Name = "GroupA", + InputAssemblies = new List { a1Path, a2Path }, + OutputAssembly = Path.Combine(_tempDir, "A.dll") + }; + + var groupB = new AssemblyGroup + { + Name = "GroupB", + InputAssemblies = new List { b1Path, b2Path }, + OutputAssembly = Path.Combine(_tempDir, "B.dll") + }; + + var config = new MultiRepackConfiguration + { + Groups = new List { groupA, groupB }, + GlobalOptions = new GlobalRepackOptions + { + SearchDirectories = new List { _tempDir } + } + }; + + // Act - Determine processing order for independent groups + using (var orchestrator = new MultiRepackOrchestrator(config, _logger)) + { + var processingOrder = orchestrator.DetermineProcessingOrder(); + + // Assert - Both groups should be in the result (order doesn't matter for independent groups) + Assert.AreEqual(2, processingOrder.Count); + Assert.IsTrue(processingOrder.Contains(groupA), "GroupA should be in processing order"); + Assert.IsTrue(processingOrder.Contains(groupB), "GroupB should be in processing order"); + } + } + + [Test] + public void ThreeLevelDependencies_CorrectOrder() + { + // Arrange - Create: C -> B -> A (C depends on B, B depends on A) + var a1Path = CreateAssembly("A1", _tempDir); + var b1Path = CreateAssemblyWithReference("B1", _tempDir, "A1"); + var c1Path = CreateAssemblyWithReference("C1", _tempDir, "B1"); + + var groupA = new AssemblyGroup + { + Name = "GroupA", + InputAssemblies = new List { a1Path }, + OutputAssembly = Path.Combine(_tempDir, "A.dll") + }; + + var groupB = new AssemblyGroup + { + Name = "GroupB", + InputAssemblies = new List { b1Path }, + OutputAssembly = Path.Combine(_tempDir, "B.dll") + }; + + var groupC = new AssemblyGroup + { + Name = "GroupC", + InputAssemblies = new List { c1Path }, + OutputAssembly = Path.Combine(_tempDir, "C.dll") + }; + + var config = new MultiRepackConfiguration + { + Groups = new List { groupC, groupB, groupA }, // Reverse order + GlobalOptions = new GlobalRepackOptions + { + SearchDirectories = new List { _tempDir } + } + }; + + // Act - Determine processing order + using (var orchestrator = new MultiRepackOrchestrator(config, _logger)) + { + var processingOrder = orchestrator.DetermineProcessingOrder(); + + // Assert - Should be sorted: A (no deps), B (depends on A), C (depends on B) + Assert.AreEqual(3, processingOrder.Count); + Assert.AreEqual("GroupA", processingOrder[0].Name, "GroupA should be first (no dependencies)"); + Assert.AreEqual("GroupB", processingOrder[1].Name, "GroupB should be second (depends on GroupA)"); + Assert.AreEqual("GroupC", processingOrder[2].Name, "GroupC should be third (depends on GroupB)"); + } + } + + // Helper methods to create test assemblies + private string CreateAssembly(string name, string outputDir) + { + var outputPath = Path.Combine(outputDir, $"{name}.dll"); + + // Create assembly using Cecil + var assemblyName = new Mono.Cecil.AssemblyNameDefinition(name, new Version(1, 0, 0, 0)); + var assembly = Mono.Cecil.AssemblyDefinition.CreateAssembly( + assemblyName, + name, + Mono.Cecil.ModuleKind.Dll); + + var testType = new Mono.Cecil.TypeDefinition( + name, + "TestClass", + Mono.Cecil.TypeAttributes.Public | Mono.Cecil.TypeAttributes.Class, + assembly.MainModule.TypeSystem.Object); + + assembly.MainModule.Types.Add(testType); + assembly.Write(outputPath); + assembly.Dispose(); + + return outputPath; + } + + private string CreateAssemblyWithReference(string name, string outputDir, string referenceName) + { + var generator = new Mono.Cecil.AssemblyNameDefinition(name, new Version(1, 0, 0, 0)); + var assembly = Mono.Cecil.AssemblyDefinition.CreateAssembly( + generator, + name, + Mono.Cecil.ModuleKind.Dll); + + // Add reference + var reference = new Mono.Cecil.AssemblyNameReference(referenceName, new Version(1, 0, 0, 0)); + assembly.MainModule.AssemblyReferences.Add(reference); + + var testType = new Mono.Cecil.TypeDefinition( + name, + "TestClass", + Mono.Cecil.TypeAttributes.Public | Mono.Cecil.TypeAttributes.Class, + assembly.MainModule.TypeSystem.Object); + + assembly.MainModule.Types.Add(testType); + + var outputPath = Path.Combine(outputDir, $"{name}.dll"); + assembly.Write(outputPath); + assembly.Dispose(); + + return outputPath; + } + + // Test logger implementation + private class TestLogger : ILogger + { + public List InfoMessages { get; } = new List(); + public List WarnMessages { get; } = new List(); + public List ErrorMessages { get; } = new List(); + + public void Log(object str) + { + InfoMessages.Add(str?.ToString()); + } + + public void Error(string msg) + { + ErrorMessages.Add(msg); + } + + public void Warn(string msg) + { + WarnMessages.Add(msg); + } + + public void Info(string msg) + { + InfoMessages.Add(msg); + } + + public void Verbose(string msg) + { + InfoMessages.Add(msg); + } + + public void DuplicateIgnored(string ignoredType, object ignoredObject) + { + InfoMessages.Add($"Duplicate ignored: {ignoredType} - {ignoredObject}"); + } + + public bool ShouldLogVerbose { get; set; } + + public void Dispose() + { + } + } + } +} + diff --git a/ILRepack/Application.cs b/ILRepack/Application.cs index 10f9ca23..1f1ac7d4 100644 --- a/ILRepack/Application.cs +++ b/ILRepack/Application.cs @@ -19,9 +19,21 @@ static int Main(string[] args) Exit(2); } - ILRepack repack = new ILRepack(options, logger); - repack.Repack(); - repack.Dispose(); + // Check if this is multi-repack mode + if (options.IsMultiRepack) + { + var config = MultiRepackConfiguration.LoadFromFile(options.MultiRepackConfigFile); + using (var orchestrator = new MultiRepackOrchestrator(config, logger)) + { + orchestrator.Repack(); + } + } + else + { + ILRepack repack = new ILRepack(options, logger); + repack.Repack(); + repack.Dispose(); + } returnCode = 0; } catch (RepackOptions.InvalidTargetKindException e) @@ -51,7 +63,9 @@ static void Usage() { Console.WriteLine($"IL Repack - assembly merging using Mono.Cecil - Version {typeof(ILRepack).Assembly.GetName().Version.ToString(3)}"); Console.WriteLine(@"Syntax: ILRepack.exe [Options] /out: [ ...]"); + Console.WriteLine(@" or: ILRepack.exe [Options] /config:"); Console.WriteLine(@" - /help displays this usage"); + Console.WriteLine(@" - /config: use multi-assembly repack mode with JSON configuration file"); Console.WriteLine(@" - /log: enable logging (to a file, if given) (default is disabled)"); Console.WriteLine(@" - /ver:M.X.Y.Z target assembly version"); Console.WriteLine(@" - /union merges types with identical names into one"); diff --git a/ILRepack/MultiRepackConfiguration.cs b/ILRepack/MultiRepackConfiguration.cs new file mode 100644 index 00000000..5495d1e7 --- /dev/null +++ b/ILRepack/MultiRepackConfiguration.cs @@ -0,0 +1,206 @@ +// +// Copyright (c) 2026 Unity Technologies +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ILRepacking +{ + /// + /// Represents the configuration for multi-assembly repacking + /// + public class MultiRepackConfiguration + { + /// + /// The assembly groups to merge + /// + [JsonPropertyName("groups")] + public List Groups { get; set; } = new List(); + + /// + /// Global options to apply to all repacking operations + /// + [JsonPropertyName("globalOptions")] + public GlobalRepackOptions GlobalOptions { get; set; } = new GlobalRepackOptions(); + + /// + /// Loads a multi-repack configuration from a JSON file + /// + public static MultiRepackConfiguration LoadFromFile(string path) + { + if (!File.Exists(path)) + throw new FileNotFoundException($"Multi-repack configuration file not found: {path}"); + + var json = File.ReadAllText(path); + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true + }; + + var config = JsonSerializer.Deserialize(json, options); + if (config == null) + throw new InvalidOperationException($"Failed to deserialize configuration from {path}"); + + config.Validate(); + return config; + } + + /// + /// Validates the configuration + /// + public void Validate() + { + if (Groups == null || Groups.Count == 0) + throw new InvalidOperationException("Multi-repack configuration must contain at least one assembly group"); + + var outputNames = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var group in Groups) + { + group.Validate(); + + if (outputNames.Contains(group.OutputAssembly)) + throw new InvalidOperationException($"Duplicate output assembly name: {group.OutputAssembly}"); + outputNames.Add(group.OutputAssembly); + } + } + + /// + /// Saves the configuration to a JSON file + /// + public void SaveToFile(string path) + { + var options = new JsonSerializerOptions + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + var json = JsonSerializer.Serialize(this, options); + File.WriteAllText(path, json); + } + } + + /// + /// Represents a group of assemblies to be merged together + /// + public class AssemblyGroup + { + /// + /// Optional name for this group (for logging purposes) + /// + [JsonPropertyName("name")] + public string Name { get; set; } + + /// + /// List of input assemblies to merge + /// + [JsonPropertyName("inputAssemblies")] + public List InputAssemblies { get; set; } = new List(); + + /// + /// The output assembly path + /// + [JsonPropertyName("outputAssembly")] + public string OutputAssembly { get; set; } + + /// + /// Optional group-specific options that override global options + /// + [JsonPropertyName("options")] + public GroupRepackOptions Options { get; set; } + + public void Validate() + { + if (InputAssemblies == null || InputAssemblies.Count == 0) + throw new InvalidOperationException($"Assembly group '{Name ?? OutputAssembly}' must contain at least one input assembly"); + + if (string.IsNullOrWhiteSpace(OutputAssembly)) + throw new InvalidOperationException($"Assembly group '{Name}' must specify an output assembly"); + } + } + + /// + /// Global options that apply to all repack operations + /// + public class GlobalRepackOptions + { + [JsonPropertyName("searchDirectories")] + public List SearchDirectories { get; set; } = new List(); + + [JsonPropertyName("internalize")] + public bool? Internalize { get; set; } + + [JsonPropertyName("debugInfo")] + public bool? DebugInfo { get; set; } + + [JsonPropertyName("copyAttributes")] + public bool? CopyAttributes { get; set; } + + [JsonPropertyName("allowMultipleAssemblyLevelAttributes")] + public bool? AllowMultipleAssemblyLevelAttributes { get; set; } + + [JsonPropertyName("xmlDocumentation")] + public bool? XmlDocumentation { get; set; } + + [JsonPropertyName("union")] + public bool? Union { get; set; } + + [JsonPropertyName("targetKind")] + public string TargetKind { get; set; } + + [JsonPropertyName("targetPlatformVersion")] + public string TargetPlatformVersion { get; set; } + + [JsonPropertyName("parallel")] + public bool? Parallel { get; set; } + + [JsonPropertyName("allowWildCards")] + public bool? AllowWildCards { get; set; } + + [JsonPropertyName("allowZeroPeKind")] + public bool? AllowZeroPeKind { get; set; } + + [JsonPropertyName("allowDuplicateResources")] + public bool? AllowDuplicateResources { get; set; } + + [JsonPropertyName("log")] + public bool? Log { get; set; } + + [JsonPropertyName("logVerbose")] + public bool? LogVerbose { get; set; } + } + + /// + /// Group-specific options that can override global options + /// + public class GroupRepackOptions : GlobalRepackOptions + { + [JsonPropertyName("version")] + public string Version { get; set; } + + [JsonPropertyName("excludeFile")] + public string ExcludeFile { get; set; } + + [JsonPropertyName("attributeFile")] + public string AttributeFile { get; set; } + } +} + diff --git a/ILRepack/MultiRepackOrchestrator.cs b/ILRepack/MultiRepackOrchestrator.cs new file mode 100644 index 00000000..4fa70d45 --- /dev/null +++ b/ILRepack/MultiRepackOrchestrator.cs @@ -0,0 +1,496 @@ +// +// Copyright (c) 2026 Unity Technologies +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using Mono.Cecil; + +namespace ILRepacking +{ + /// + /// Orchestrates multiple repack operations with cross-assembly reference rewriting + /// + public class MultiRepackOrchestrator : IDisposable + { + private readonly MultiRepackConfiguration _config; + private readonly ILogger _logger; + private readonly Dictionary _assemblyToGroupMap = new Dictionary(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _outputAssemblyMap = new Dictionary(StringComparer.OrdinalIgnoreCase); + private readonly List _alreadyMergedAssemblies = new List(); + private List _sortedGroups; + + public MultiRepackOrchestrator(MultiRepackConfiguration config, ILogger logger) + { + _config = config ?? throw new ArgumentNullException(nameof(config)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Determines the processing order for assembly groups based on their dependencies. + /// Returns the groups in topologically sorted order (dependencies first). + /// + /// List of assembly groups in the order they should be processed + internal List DetermineProcessingOrder() + { + // Build mapping of input assemblies to groups + BuildAssemblyGroupMap(); + + // Detect circular dependencies and determine merge order + return TopologicalSort(); + } + + /// + /// Performs the multi-assembly repack operation + /// + public void Repack() + { + var timer = Stopwatch.StartNew(); + _logger.Info("Starting multi-assembly repack"); + _logger.Info($"Processing {_config.Groups.Count} assembly groups"); + + try + { + // Determine merge order + _sortedGroups = DetermineProcessingOrder(); + _logger.Info($"Merge order determined: {string.Join(" -> ", _sortedGroups.Select(g => g.Name ?? g.OutputAssembly))}"); + + // Perform each repack operation in order + for (int i = 0; i < _sortedGroups.Count; i++) + { + var group = _sortedGroups[i]; + _logger.Info($"Processing group {i + 1}/{_sortedGroups.Count}: {group.Name ?? group.OutputAssembly}"); + + var mergedAssemblies = PerformGroupRepack(group); + + // Track the output assembly for reference rewriting + foreach (var assembly in mergedAssemblies) + { + var assemblyName = assembly.Name.Name; + _outputAssemblyMap[assemblyName] = group.OutputAssembly; + } + + // Rewrite references in the just-merged assembly to point to previously merged assemblies + if (i > 0) + { + RewriteAssemblyReferences(group.OutputAssembly); + } + + _alreadyMergedAssemblies.AddRange(mergedAssemblies); + } + + _logger.Info($"Multi-assembly repack completed in {timer.Elapsed}"); + } + catch (Exception ex) + { + _logger.Error($"Multi-assembly repack failed: {ex.Message}"); + throw; + } + } + + private void BuildAssemblyGroupMap() + { + foreach (var group in _config.Groups) + { + foreach (var assembly in group.InputAssemblies) + { + var assemblyName = Path.GetFileNameWithoutExtension(assembly); + if (_assemblyToGroupMap.ContainsKey(assemblyName)) + { + throw new InvalidOperationException( + $"Assembly '{assemblyName}' appears in multiple groups. Each input assembly must belong to exactly one group."); + } + _assemblyToGroupMap[assemblyName] = group; + } + } + } + + /// + /// Performs topological sort to determine merge order and detect circular dependencies + /// + private List TopologicalSort() + { + var dependencies = BuildDependencyGraph(); + + var sorted = new List(); + var visited = new HashSet(); + var visiting = new HashSet(); + + foreach (var group in _config.Groups) + { + if (!visited.Contains(group)) + { + Visit(group, dependencies, visited, visiting, sorted); + } + } + + return sorted; + } + + private void Visit( + AssemblyGroup group, + Dictionary> dependencies, + HashSet visited, + HashSet visiting, + List sorted) + { + if (visiting.Contains(group)) + { + var cycle = BuildCycleDescription(group, visiting); + throw new InvalidOperationException( + $"Circular dependency detected between assembly groups: {cycle}"); + } + + if (visited.Contains(group)) + return; + + visiting.Add(group); + + if (dependencies.TryGetValue(group, out var deps)) + { + foreach (var dep in deps) + { + Visit(dep, dependencies, visited, visiting, sorted); + } + } + + visiting.Remove(group); + visited.Add(group); + sorted.Add(group); + } + + private string BuildCycleDescription(AssemblyGroup startGroup, HashSet visiting) + { + var cycle = visiting.Select(g => g.Name ?? g.OutputAssembly).ToList(); + return string.Join(" -> ", cycle) + $" -> {startGroup.Name ?? startGroup.OutputAssembly}"; + } + + /// + /// Builds a dependency graph between assembly groups + /// + private Dictionary> BuildDependencyGraph() + { + var dependencies = new Dictionary>(); + + foreach (var group in _config.Groups) + { + var groupDeps = new HashSet(); + + foreach (var inputAssembly in group.InputAssemblies) + { + if (!File.Exists(inputAssembly)) + { + _logger.Warn($"Input assembly not found: {inputAssembly}"); + continue; + } + + try + { + using (var assembly = AssemblyDefinition.ReadAssembly(inputAssembly, new ReaderParameters { ReadingMode = ReadingMode.Deferred })) + { + foreach (var reference in assembly.MainModule.AssemblyReferences) + { + if (_assemblyToGroupMap.TryGetValue(reference.Name, out var depGroup)) + { + if (depGroup != group) + { + groupDeps.Add(depGroup); + } + } + } + } + } + catch (Exception ex) + { + _logger.Warn($"Failed to read assembly {inputAssembly}: {ex.Message}"); + } + } + + if (groupDeps.Count > 0) + { + dependencies[group] = groupDeps; + } + } + + return dependencies; + } + + private IList PerformGroupRepack(AssemblyGroup group) + { + var options = CreateRepackOptions(group); + + using (var repack = new ILRepack(options, _logger)) + { + // If this is not the first group, we need to use a custom assembly resolver + // that can redirect references to merged assemblies + if (_outputAssemblyMap.Count > 0) + { + SetupReferenceRedirection(repack.GlobalAssemblyResolver); + } + + repack.Repack(); + + return repack.MergedAssemblies; + } + } + + private void SetupReferenceRedirection(RepackAssemblyResolver resolver) + { + // Register the already-merged assemblies with the resolver + // and add their directories to the search path + var addedDirectories = new HashSet(); + + foreach (var kvp in _outputAssemblyMap) + { + var inputAssemblyName = kvp.Key; + var outputPath = kvp.Value; + + if (File.Exists(outputPath)) + { + try + { + // Add the output directory to search directories + var outputDir = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrEmpty(outputDir) && addedDirectories.Add(outputDir)) + { + resolver.AddSearchDirectory(outputDir); + } + + // Read and register the merged assembly + var assembly = AssemblyDefinition.ReadAssembly(outputPath, new ReaderParameters + { + AssemblyResolver = resolver, + ReadingMode = ReadingMode.Deferred + }); + resolver.RegisterAssembly(assembly); + + _logger.Verbose($"Registered merged assembly: {inputAssemblyName} -> {Path.GetFileName(outputPath)}"); + } + catch (Exception ex) + { + _logger.Warn($"Failed to register merged assembly {outputPath}: {ex.Message}"); + } + } + } + } + + private RepackOptions CreateRepackOptions(AssemblyGroup group) + { + var options = new RepackOptions(); + + // Set input assemblies + options.InputAssemblies = group.InputAssemblies.ToArray(); + options.OutputFile = group.OutputAssembly; + + // Initialize SearchDirectories to empty list to avoid null reference + options.SearchDirectories = new List(); + + // Apply global options + ApplyGlobalOptions(options, _config.GlobalOptions); + + // Apply group-specific options (these override global options) + if (group.Options != null) + { + ApplyGroupOptions(options, group.Options); + } + + return options; + } + + private void ApplyGlobalOptions(RepackOptions options, GlobalRepackOptions globalOptions) + { + if (globalOptions.SearchDirectories != null && globalOptions.SearchDirectories.Count > 0) + options.SearchDirectories = globalOptions.SearchDirectories; + + if (globalOptions.Internalize.HasValue) + options.Internalize = globalOptions.Internalize.Value; + + if (globalOptions.DebugInfo.HasValue) + options.DebugInfo = globalOptions.DebugInfo.Value; + + if (globalOptions.CopyAttributes.HasValue) + options.CopyAttributes = globalOptions.CopyAttributes.Value; + + if (globalOptions.AllowMultipleAssemblyLevelAttributes.HasValue) + options.AllowMultipleAssemblyLevelAttributes = globalOptions.AllowMultipleAssemblyLevelAttributes.Value; + + if (globalOptions.XmlDocumentation.HasValue) + options.XmlDocumentation = globalOptions.XmlDocumentation.Value; + + if (globalOptions.Union.HasValue) + options.UnionMerge = globalOptions.Union.Value; + + if (globalOptions.Parallel.HasValue) + options.Parallel = globalOptions.Parallel.Value; + + if (globalOptions.AllowWildCards.HasValue) + options.AllowWildCards = globalOptions.AllowWildCards.Value; + + if (globalOptions.AllowZeroPeKind.HasValue) + options.AllowZeroPeKind = globalOptions.AllowZeroPeKind.Value; + + if (globalOptions.AllowDuplicateResources.HasValue) + options.AllowDuplicateResources = globalOptions.AllowDuplicateResources.Value; + + if (globalOptions.Log.HasValue) + options.Log = globalOptions.Log.Value; + + if (globalOptions.LogVerbose.HasValue) + options.LogVerbose = globalOptions.LogVerbose.Value; + + if (!string.IsNullOrWhiteSpace(globalOptions.TargetKind)) + { + options.TargetKind = ParseTargetKind(globalOptions.TargetKind); + } + + if (!string.IsNullOrWhiteSpace(globalOptions.TargetPlatformVersion)) + options.TargetPlatformVersion = globalOptions.TargetPlatformVersion; + } + + private void ApplyGroupOptions(RepackOptions options, GroupRepackOptions groupOptions) + { + // Apply base global options + ApplyGlobalOptions(options, groupOptions); + + // Apply group-specific options + if (!string.IsNullOrWhiteSpace(groupOptions.Version)) + options.Version = new Version(groupOptions.Version); + + if (!string.IsNullOrWhiteSpace(groupOptions.ExcludeFile)) + options.ExcludeFile = groupOptions.ExcludeFile; + + if (!string.IsNullOrWhiteSpace(groupOptions.AttributeFile)) + options.AttributeFile = groupOptions.AttributeFile; + } + + private ILRepack.Kind ParseTargetKind(string targetKind) + { + switch (targetKind.ToLowerInvariant()) + { + case "dll": + case "library": + return ILRepack.Kind.Dll; + case "exe": + return ILRepack.Kind.Exe; + case "winexe": + return ILRepack.Kind.WinExe; + default: + throw new ArgumentException($"Invalid target kind: {targetKind}"); + } + } + + /// + /// Rewrites assembly references in a merged assembly to point to other merged assemblies + /// + private void RewriteAssemblyReferences(string assemblyPath) + { + if (!File.Exists(assemblyPath)) + return; + + try + { + var resolver = new RepackAssemblyResolver(); + resolver.Mode = AssemblyResolverMode.Core; + + foreach (var dir in _config.GlobalOptions.SearchDirectories) + resolver.AddSearchDirectory(dir); + SetupReferenceRedirection(resolver); + foreach (var alreadyMergedAssembly in _alreadyMergedAssemblies) + resolver.RegisterAssembly(alreadyMergedAssembly); + + var assembly = AssemblyDefinition.ReadAssembly(assemblyPath, new ReaderParameters(ReadingMode.Immediate) + { + InMemory = true, + AssemblyResolver = resolver + }); + + bool modified = false; + + // Check each assembly reference and rewrite if needed + for (int i = 0; i < assembly.MainModule.AssemblyReferences.Count; i++) + { + var reference = assembly.MainModule.AssemblyReferences[i]; + + // Check if this reference should be redirected to a merged assembly + if (_outputAssemblyMap.TryGetValue(reference.Name, out var mergedAssemblyPath)) + { + // Get the name of the merged assembly + var mergedAssemblyName = Path.GetFileNameWithoutExtension(mergedAssemblyPath); + + // Only rewrite if the names are different + if (reference.Name != mergedAssemblyName) + { + _logger.Info($"Rewriting reference: {reference.Name} -> {mergedAssemblyName}"); + + // Create a new reference with the merged assembly name + var newReference = new AssemblyNameReference(mergedAssemblyName, reference.Version) + { + Culture = reference.Culture, + PublicKeyToken = reference.PublicKeyToken, + HashAlgorithm = reference.HashAlgorithm + }; + + assembly.MainModule.AssemblyReferences[i] = newReference; + modified = true; + } + } + } + + // Remove duplicate references (e.g., when A1 and A2 both map to MergedA) + if (modified) + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var duplicates = new List(); + + foreach (var reference in assembly.MainModule.AssemblyReferences) + { + if (!seen.Add(reference.Name)) + { + duplicates.Add(reference); + _logger.Verbose($"Removing duplicate reference: {reference.Name}"); + } + } + + foreach (var duplicate in duplicates) + { + assembly.MainModule.AssemblyReferences.Remove(duplicate); + } + } + + // Save the assembly if we made changes + if (modified) + { + assembly.Write(assemblyPath); + _logger.Verbose($"Updated assembly references in {Path.GetFileName(assemblyPath)}"); + } + + assembly.Dispose(); + } + catch (Exception ex) + { + _logger.Error($"Failed to rewrite references in {assemblyPath}: {ex.Message}"); + throw; + } + } + + public void Dispose() + { + // Cleanup if needed + } + } +} + diff --git a/ILRepack/RepackAssemblyResolver.cs b/ILRepack/RepackAssemblyResolver.cs index 665c3641..ea366695 100644 --- a/ILRepack/RepackAssemblyResolver.cs +++ b/ILRepack/RepackAssemblyResolver.cs @@ -233,6 +233,9 @@ private AssemblyDefinition ResolveCore(AssemblyNameReference name, ReaderParamet { var assembly = ResolveBase(ref name, parameters); if (assembly != null) return assembly; + + if (all_core_paths == null) + FindCoreSdkFolders(); assembly = SearchDirectoryCheckVersion(name, all_core_paths, parameters); if (assembly != null) diff --git a/ILRepack/RepackOptions.cs b/ILRepack/RepackOptions.cs index 932eba54..1c8df7a3 100644 --- a/ILRepack/RepackOptions.cs +++ b/ILRepack/RepackOptions.cs @@ -74,6 +74,7 @@ public string ExcludeFile // end of ILMerge-similar attributes public bool LogVerbose { get; set; } + public string MultiRepackConfigFile { get; set; } public bool NoRepackRes { get; set; } public bool KeepOtherVersionReferences { get; set; } public bool LineIndexation { get; set; } @@ -146,7 +147,7 @@ internal RepackOptions(ICommandLine commandLine, IFile file) Parse(); } - internal bool ShouldShowUsage => cmd.Modifier("?") || cmd.Modifier("help") || cmd.Modifier("h") || cmd.HasNoOptions; + internal bool ShouldShowUsage => cmd.Modifier("?") || cmd.Modifier("help") || cmd.Modifier("h") || (cmd.HasNoOptions && !IsMultiRepack); void Parse() { @@ -238,15 +239,33 @@ void Parse() } } + // Multi-repack configuration file + MultiRepackConfigFile = cmd.Option("config"); + // everything that doesn't start with a '/' must be a file to merge (verify when loading the files) InputAssemblies = cmd.OtherAguments; } + /// + /// Returns true if this is a multi-repack configuration + /// + public bool IsMultiRepack => !string.IsNullOrWhiteSpace(MultiRepackConfigFile); + /// /// Validates the options for repack execution, throws upon invalid argument set /// internal void Validate() { + // If multi-repack mode, different validation rules apply + if (IsMultiRepack) + { + if (!string.IsNullOrEmpty(OutputFile)) + throw new InvalidOperationException("Option 'out' cannot be used with 'config' (multi-repack mode)."); + if (InputAssemblies != null && InputAssemblies.Length > 0) + throw new InvalidOperationException("Input assemblies cannot be specified directly with 'config' (multi-repack mode)."); + return; + } + if (AllowMultipleAssemblyLevelAttributes && !CopyAttributes) throw new InvalidOperationException("Option 'allowMultiple' is only valid with 'copyattrs'."); diff --git a/README.md b/README.md index 40990476..237301f0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,3 @@ -[![Build status](https://img.shields.io/appveyor/ci/Alexx999/il-repack.svg?label=build%20windows)](https://ci.appveyor.com/project/Alexx999/il-repack) [![NuGet](https://img.shields.io/nuget/v/ILRepack.svg)](https://www.nuget.org/packages/avostres.ILRepack/) [![GitHub license](https://img.shields.io/github/license/gluck/il-repack.svg)](http://www.apache.org/licenses/LICENSE-2.0) -[![Gitter chat](https://img.shields.io/badge/gitter-join%20chat-green.svg)](https://gitter.im/gluck/il-repack) - Introduction ============ @@ -9,15 +6,7 @@ ILRepack is meant at replacing [ILMerge](http://www.microsoft.com/downloads/deta The former being ~~closed-source~~ ([now open-sourced](https://github.com/Microsoft/ILMerge)), impossible to customize, slow, resource consuming and many more. The later being deprecated, unsupported, and based on an old version of Mono.Cecil. -Here we're using latest (slightly modified) Cecil sources (0.9), you can find the fork [here](https://github.com/gluck/cecil). -Mono.Posix is also required (build only, it gets merged afterwards) for executable bit set on target file. - -Downloads ------- - -You can grab it using [NuGet](http://nuget.org/packages/ILRepack/). - -Or if you're old-school (and want to stay like that), this [direct link](http://nuget.org/api/v2/package/ILRepack) will give you the latest nupkg file, which you can open as a zip file. +This fork is using the latest Cecil, and has been updated to run on .NET 8 and to be buildable/testable/runnable cross-platform. Support for WPF, and older .NET features such as signing the assembly with a strong name, have been stripped out. Syntax ------ @@ -25,8 +14,10 @@ Syntax A console application is available (can be used as DLL as well), using same syntax as ILMerge: ``` Syntax: ILRepack.exe [options] /out: [ ...] + or: ILRepack.exe [options] /config: - /help displays this usage + - /config: use multi-assembly repack mode with JSON configuration file - /log: enable logging (to a file, if given) (default is disabled) - /ver:M.X.Y.Z target assembly version - /union merges types with identical names into one @@ -56,19 +47,60 @@ Syntax: ILRepack.exe [options] /out: [ Note: for compatibility purposes, all options can be specified using '/', '-' or '--' prefix. ``` +Multi-Assembly Repack Mode +------ + +ILRepack now supports merging multiple groups of assemblies into separate output assemblies. This is useful as a 'batch mode' when you want to do many merges, but is also supports automatic reference rewriting. Suppose you have: + +``` +Group A: {A1.dll, A2.dll, A3.dll} -> A.dll +Group B: {B1.dll, B2.dll, B3.dll} -> B.dll +``` + +And `B1.dll` references `A1.dll`. + +If you just do those two merges as separate operations, then `B.dll` will end up referencing `A1.dll` still. But with this feature, `B.dll` will have any references to `A1.dll`, `A2.dll` or `A3.dll` rewritten to point at `A.dll`. + +**Usage:** + +Create a JSON configuration file, for example: +```json +{ + "groups": [ + { + "name": "CoreGroup", + "inputAssemblies": ["Core.dll", "Utilities.dll"], + "outputAssembly": "MyApp.Core.dll" + }, + { + "name": "UIGroup", + "inputAssemblies": ["UI.dll", "Controls.dll"], + "outputAssembly": "MyApp.UI.dll" + } + ], + "globalOptions": { + "internalize": true, + "debugInfo": true + } +} +``` + +Then run: +```bash +ILRepack.exe /config:repack-config.json +``` + How to build ------ -Builds directly from within Visual Studio 2015, or using gradle: +Builds directly from within Visual Studio 2015, or using dotnet: ``` git clone --recursive https://github.com/gluck/il-repack.git cd il-repack -gradlew.bat msbuild +dotnet build ``` -(Mono.Posix 3.5-compatible dependency was grabbed from a non-standard nuget repo, it has been commited to git to avoid the dependency on this repo) - TODO ------ * Crash-testing @@ -77,6 +109,9 @@ TODO DONE ------ + * Multi-assembly repack mode with reference rewriting + * Circular dependency detection for multi-assembly mode + * JSON-based configuration for complex merge scenarios * PDBs & MDBs should be merged (Thanks Simon) * Fixed internal method overriding public one which isn't allowed in the same assembly (Simon) * Attribute merge (/copyattrs)