Copying project dependencies when the project is a test project. Without this, dotnet test will fail to find the P2P dependencies and crash.
Refactoring the MakeCompilationOutputRunnable code so that it is easier to move it to build. Making build stop passing runtime to compile. Moving the MakeCompilationOutputRunnable from compile to build. Making WriteDepsFile create its directory if it does not exist.
This commit is contained in:
parent
b16ecff0e9
commit
35ef45306a
7 changed files with 283 additions and 185 deletions
96
src/Microsoft.DotNet.Compiler.Common/ContentFiles.cs
Normal file
96
src/Microsoft.DotNet.Compiler.Common/ContentFiles.cs
Normal file
|
@ -0,0 +1,96 @@
|
|||
// Copyright (c) .NET Foundation and contributors. All rights reserved.
|
||||
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Microsoft.DotNet.ProjectModel;
|
||||
using Microsoft.DotNet.Tools.Common;
|
||||
|
||||
namespace Microsoft.Dotnet.Cli.Compiler.Common
|
||||
{
|
||||
public class ContentFiles
|
||||
{
|
||||
private readonly ProjectContext _context;
|
||||
|
||||
public ContentFiles(ProjectContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public void StructuredCopyTo(string targetDirectory)
|
||||
{
|
||||
var sourceFiles = _context
|
||||
.ProjectFile
|
||||
.Files
|
||||
.GetContentFiles();
|
||||
|
||||
var sourceDirectory = _context.ProjectDirectory;
|
||||
|
||||
if (sourceFiles == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(sourceFiles));
|
||||
}
|
||||
|
||||
sourceDirectory = EnsureTrailingSlash(sourceDirectory);
|
||||
targetDirectory = EnsureTrailingSlash(targetDirectory);
|
||||
|
||||
var pathMap = sourceFiles
|
||||
.ToDictionary(s => s,
|
||||
s => Path.Combine(targetDirectory,
|
||||
PathUtility.GetRelativePath(sourceDirectory, s)));
|
||||
|
||||
foreach (var targetDir in pathMap.Values
|
||||
.Select(Path.GetDirectoryName)
|
||||
.Distinct()
|
||||
.Where(t => !Directory.Exists(t)))
|
||||
{
|
||||
Directory.CreateDirectory(targetDir);
|
||||
}
|
||||
|
||||
foreach (var sourceFilePath in pathMap.Keys)
|
||||
{
|
||||
File.Copy(
|
||||
sourceFilePath,
|
||||
pathMap[sourceFilePath],
|
||||
overwrite: true);
|
||||
}
|
||||
|
||||
RemoveAttributeFromFiles(pathMap.Values, FileAttributes.ReadOnly);
|
||||
}
|
||||
|
||||
private static void RemoveAttributeFromFiles(IEnumerable<string> files, FileAttributes attribute)
|
||||
{
|
||||
foreach (var file in files)
|
||||
{
|
||||
var fileAttributes = File.GetAttributes(file);
|
||||
if ((fileAttributes & attribute) == attribute)
|
||||
{
|
||||
File.SetAttributes(file, fileAttributes & ~attribute);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string EnsureTrailingSlash(string path)
|
||||
{
|
||||
return EnsureTrailingCharacter(path, Path.DirectorySeparatorChar);
|
||||
}
|
||||
|
||||
private static string EnsureTrailingCharacter(string path, char trailingCharacter)
|
||||
{
|
||||
if (path == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(path));
|
||||
}
|
||||
|
||||
// if the path is empty, we want to return the original string instead of a single trailing character.
|
||||
if (path.Length == 0 || path[path.Length - 1] == trailingCharacter)
|
||||
{
|
||||
return path;
|
||||
}
|
||||
|
||||
return path + trailingCharacter;
|
||||
}
|
||||
}
|
||||
}
|
129
src/Microsoft.DotNet.Compiler.Common/Executable.cs
Normal file
129
src/Microsoft.DotNet.Compiler.Common/Executable.cs
Normal file
|
@ -0,0 +1,129 @@
|
|||
// Copyright (c) .NET Foundation and contributors. All rights reserved.
|
||||
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Xml.Linq;
|
||||
using Microsoft.DotNet.Cli.Compiler.Common;
|
||||
using Microsoft.DotNet.Cli.Utils;
|
||||
using Microsoft.DotNet.ProjectModel;
|
||||
using Microsoft.DotNet.ProjectModel.Compilation;
|
||||
using Microsoft.DotNet.ProjectModel.Graph;
|
||||
using NuGet.Frameworks;
|
||||
|
||||
namespace Microsoft.Dotnet.Cli.Compiler.Common
|
||||
{
|
||||
public class Executable
|
||||
{
|
||||
private readonly ProjectContext _context;
|
||||
|
||||
private readonly OutputPathCalculator _calculator;
|
||||
|
||||
public Executable(ProjectContext context, OutputPathCalculator calculator)
|
||||
{
|
||||
_context = context;
|
||||
|
||||
_calculator = calculator;
|
||||
}
|
||||
|
||||
public void MakeCompilationOutputRunnable(string configuration)
|
||||
{
|
||||
var outputPath = _calculator.GetOutputDirectoryPath(configuration);
|
||||
|
||||
CopyContentFiles(outputPath);
|
||||
|
||||
ExportRuntimeAssets(outputPath, configuration);
|
||||
}
|
||||
|
||||
private void ExportRuntimeAssets(string outputPath, string configuration)
|
||||
{
|
||||
var exporter = _context.CreateExporter(configuration);
|
||||
|
||||
if (_context.TargetFramework.IsDesktop())
|
||||
{
|
||||
MakeCompilationOutputRunnableForFullFramework(outputPath, configuration, exporter);
|
||||
}
|
||||
else
|
||||
{
|
||||
MakeCompilationOutputRunnableForCoreCLR(outputPath, exporter);
|
||||
}
|
||||
}
|
||||
|
||||
private void MakeCompilationOutputRunnableForFullFramework(
|
||||
string outputPath,
|
||||
string configuration,
|
||||
LibraryExporter exporter)
|
||||
{
|
||||
CopyAllDependencies(outputPath, exporter);
|
||||
|
||||
GenerateBindingRedirects(exporter, configuration);
|
||||
}
|
||||
|
||||
private void MakeCompilationOutputRunnableForCoreCLR(string outputPath, LibraryExporter exporter)
|
||||
{
|
||||
WriteDepsFileAndCopyProjectDependencies(exporter, _context.ProjectFile.Name, outputPath);
|
||||
|
||||
// TODO: Pick a host based on the RID
|
||||
CoreHost.CopyTo(outputPath, _context.ProjectFile.Name + Constants.ExeSuffix);
|
||||
}
|
||||
|
||||
private void CopyContentFiles(string outputPath)
|
||||
{
|
||||
var contentFiles = new ContentFiles(_context);
|
||||
contentFiles.StructuredCopyTo(outputPath);
|
||||
}
|
||||
|
||||
private static void CopyAllDependencies(string outputPath, LibraryExporter exporter)
|
||||
{
|
||||
exporter
|
||||
.GetDependencies()
|
||||
.SelectMany(e => e.RuntimeAssets())
|
||||
.CopyTo(outputPath);
|
||||
}
|
||||
|
||||
private static void WriteDepsFileAndCopyProjectDependencies(
|
||||
LibraryExporter exporter,
|
||||
string projectFileName,
|
||||
string outputPath)
|
||||
{
|
||||
exporter
|
||||
.GetDependencies(LibraryType.Package)
|
||||
.WriteDepsTo(Path.Combine(outputPath, projectFileName + FileNameSuffixes.Deps));
|
||||
|
||||
exporter
|
||||
.GetDependencies(LibraryType.Project)
|
||||
.SelectMany(e => e.RuntimeAssets())
|
||||
.CopyTo(outputPath);
|
||||
}
|
||||
|
||||
public void GenerateBindingRedirects(LibraryExporter exporter, string configuration)
|
||||
{
|
||||
var outputName = _calculator.GetAssemblyPath(configuration);
|
||||
|
||||
var existingConfig = new DirectoryInfo(_context.ProjectDirectory)
|
||||
.EnumerateFiles()
|
||||
.FirstOrDefault(f => f.Name.Equals("app.config", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
XDocument baseAppConfig = null;
|
||||
|
||||
if (existingConfig != null)
|
||||
{
|
||||
using (var fileStream = File.OpenRead(existingConfig.FullName))
|
||||
{
|
||||
baseAppConfig = XDocument.Load(fileStream);
|
||||
}
|
||||
}
|
||||
|
||||
var appConfig = exporter.GetAllExports().GenerateBindingRedirects(baseAppConfig);
|
||||
|
||||
if (appConfig == null) { return; }
|
||||
|
||||
var path = outputName + ".config";
|
||||
using (var stream = File.Create(path))
|
||||
{
|
||||
appConfig.Save(stream);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -9,9 +9,17 @@ namespace Microsoft.DotNet.Cli.Compiler.Common
|
|||
{
|
||||
public static void WriteDepsTo(this IEnumerable<LibraryExport> exports, string path)
|
||||
{
|
||||
CreateDirectoryIfNotExists(path);
|
||||
|
||||
File.WriteAllLines(path, exports.SelectMany(GenerateLines));
|
||||
}
|
||||
|
||||
|
||||
private static void CreateDirectoryIfNotExists(string path)
|
||||
{
|
||||
var depsFile = new FileInfo(path);
|
||||
depsFile.Directory.Create();
|
||||
}
|
||||
|
||||
private static IEnumerable<string> GenerateLines(LibraryExport export)
|
||||
{
|
||||
return GenerateLines(export, export.RuntimeAssemblies, "runtime")
|
||||
|
|
|
@ -2,16 +2,9 @@
|
|||
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
|
||||
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Xml.Linq;
|
||||
using Microsoft.DotNet.Cli.Utils;
|
||||
using Microsoft.DotNet.ProjectModel;
|
||||
using Microsoft.DotNet.ProjectModel.Compilation;
|
||||
using Microsoft.DotNet.ProjectModel.Graph;
|
||||
using Microsoft.DotNet.Tools.Common;
|
||||
using NuGet.Frameworks;
|
||||
|
||||
namespace Microsoft.DotNet.Cli.Compiler.Common
|
||||
|
@ -21,141 +14,7 @@ namespace Microsoft.DotNet.Cli.Compiler.Common
|
|||
public static string ProjectName(this ProjectContext context) => context.RootProject.Identity.Name;
|
||||
public static string GetDisplayName(this ProjectContext context) => $"{context.RootProject.Identity.Name} ({context.TargetFramework})";
|
||||
|
||||
public static void MakeCompilationOutputRunnable(this ProjectContext context, string outputPath, string configuration)
|
||||
{
|
||||
// REVIEW: This shouldn't be copied on compile
|
||||
context
|
||||
.ProjectFile
|
||||
.Files
|
||||
.GetContentFiles()
|
||||
.StructuredCopyTo(context.ProjectDirectory, outputPath)
|
||||
.RemoveAttribute(FileAttributes.ReadOnly);
|
||||
|
||||
var exporter = context.CreateExporter(configuration);
|
||||
|
||||
if (context.TargetFramework.IsDesktop())
|
||||
{
|
||||
// On full framework, copy all dependencies to the output path
|
||||
exporter
|
||||
.GetDependencies()
|
||||
.SelectMany(e => e.RuntimeAssets())
|
||||
.CopyTo(outputPath);
|
||||
|
||||
// Generate binding redirects
|
||||
var outputName = context.GetOutputPathCalculator(outputPath).GetAssemblyPath(configuration);
|
||||
context.GenerateBindingRedirects(exporter, outputName);
|
||||
}
|
||||
else
|
||||
{
|
||||
exporter
|
||||
.GetDependencies(LibraryType.Package)
|
||||
.WriteDepsTo(Path.Combine(outputPath, context.ProjectFile.Name + FileNameSuffixes.Deps));
|
||||
|
||||
// On core clr, only copy project references
|
||||
exporter.GetDependencies(LibraryType.Project)
|
||||
.SelectMany(e => e.RuntimeAssets())
|
||||
.CopyTo(outputPath);
|
||||
|
||||
// TODO: Pick a host based on the RID
|
||||
CoreHost.CopyTo(outputPath, context.ProjectFile.Name + Constants.ExeSuffix);
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> StructuredCopyTo(this IEnumerable<string> sourceFiles, string sourceDirectory, string targetDirectory)
|
||||
{
|
||||
if (sourceFiles == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(sourceFiles));
|
||||
}
|
||||
|
||||
sourceDirectory = EnsureTrailingSlash(sourceDirectory);
|
||||
targetDirectory = EnsureTrailingSlash(targetDirectory);
|
||||
|
||||
var pathMap = sourceFiles
|
||||
.ToDictionary(s => s,
|
||||
s => Path.Combine(targetDirectory,
|
||||
PathUtility.GetRelativePath(sourceDirectory, s)));
|
||||
|
||||
foreach (var targetDir in pathMap.Values
|
||||
.Select(Path.GetDirectoryName)
|
||||
.Distinct()
|
||||
.Where(t => !Directory.Exists(t)))
|
||||
{
|
||||
Directory.CreateDirectory(targetDir);
|
||||
}
|
||||
|
||||
foreach (var sourceFilePath in pathMap.Keys)
|
||||
{
|
||||
File.Copy(
|
||||
sourceFilePath,
|
||||
pathMap[sourceFilePath],
|
||||
overwrite: true);
|
||||
}
|
||||
|
||||
return pathMap.Values;
|
||||
}
|
||||
|
||||
private static string EnsureTrailingSlash(string path)
|
||||
{
|
||||
return EnsureTrailingCharacter(path, Path.DirectorySeparatorChar);
|
||||
}
|
||||
|
||||
private static string EnsureTrailingCharacter(string path, char trailingCharacter)
|
||||
{
|
||||
if (path == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(path));
|
||||
}
|
||||
|
||||
// if the path is empty, we want to return the original string instead of a single trailing character.
|
||||
if (path.Length == 0 || path[path.Length - 1] == trailingCharacter)
|
||||
{
|
||||
return path;
|
||||
}
|
||||
|
||||
return path + trailingCharacter;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> RemoveAttribute(this IEnumerable<string> files, FileAttributes attribute)
|
||||
{
|
||||
foreach (var file in files)
|
||||
{
|
||||
var fileAttributes = File.GetAttributes(file);
|
||||
if ((fileAttributes & attribute) == attribute)
|
||||
{
|
||||
File.SetAttributes(file, fileAttributes & ~attribute);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
public static void GenerateBindingRedirects(this ProjectContext context, LibraryExporter exporter, string outputName)
|
||||
{
|
||||
var existingConfig = new DirectoryInfo(context.ProjectDirectory)
|
||||
.EnumerateFiles()
|
||||
.FirstOrDefault(f => f.Name.Equals("app.config", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
XDocument baseAppConfig = null;
|
||||
|
||||
if (existingConfig != null)
|
||||
{
|
||||
using (var fileStream = File.OpenRead(existingConfig.FullName))
|
||||
{
|
||||
baseAppConfig = XDocument.Load(fileStream);
|
||||
}
|
||||
}
|
||||
|
||||
var appConfig = exporter.GetAllExports().GenerateBindingRedirects(baseAppConfig);
|
||||
|
||||
if (appConfig == null) { return; }
|
||||
|
||||
var path = outputName + ".config";
|
||||
using (var stream = File.Create(path))
|
||||
{
|
||||
appConfig.Save(stream);
|
||||
}
|
||||
}
|
||||
public static bool IsTestProject(this ProjectContext context) => !string.IsNullOrEmpty(context.ProjectFile.TestRunner);
|
||||
|
||||
public static CommonCompilerOptions GetLanguageSpecificCompilerOptions(this ProjectContext context, NuGetFramework framework, string configurationName)
|
||||
{
|
||||
|
|
|
@ -5,11 +5,13 @@ using System;
|
|||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Microsoft.Dotnet.Cli.Compiler.Common;
|
||||
using Microsoft.DotNet.Cli.Compiler.Common;
|
||||
using Microsoft.DotNet.Cli.Utils;
|
||||
using Microsoft.DotNet.ProjectModel;
|
||||
using Microsoft.DotNet.ProjectModel.Utilities;
|
||||
using Microsoft.DotNet.Tools.Compiler;
|
||||
using Microsoft.Extensions.PlatformAbstractions;
|
||||
|
||||
namespace Microsoft.DotNet.Tools.Build
|
||||
{
|
||||
|
@ -273,13 +275,7 @@ namespace Microsoft.DotNet.Tools.Build
|
|||
var args = new List<string>();
|
||||
|
||||
args.Add("--framework");
|
||||
args.Add($"{projectDependency.Framework}");
|
||||
|
||||
if (!string.IsNullOrEmpty(_args.RuntimeValue))
|
||||
{
|
||||
args.Add("--runtime");
|
||||
args.Add(_args.RuntimeValue);
|
||||
}
|
||||
args.Add($"{projectDependency.Framework}");
|
||||
|
||||
args.Add("--configuration");
|
||||
args.Add(_args.ConfigValue);
|
||||
|
@ -298,12 +294,7 @@ namespace Microsoft.DotNet.Tools.Build
|
|||
// todo: add methods to CompilerCommandApp to generate the arg string?
|
||||
var args = new List<string>();
|
||||
args.Add("--framework");
|
||||
args.Add(_rootProject.TargetFramework.ToString());
|
||||
if (!string.IsNullOrEmpty(_args.RuntimeValue))
|
||||
{
|
||||
args.Add("--runtime");
|
||||
args.Add(_args.RuntimeValue);
|
||||
}
|
||||
args.Add(_rootProject.TargetFramework.ToString());
|
||||
args.Add("--configuration");
|
||||
args.Add(_args.ConfigValue);
|
||||
args.Add("--output");
|
||||
|
@ -353,7 +344,41 @@ namespace Microsoft.DotNet.Tools.Build
|
|||
.ForwardStdErr()
|
||||
.Execute();
|
||||
|
||||
return compileResult.ExitCode == 0;
|
||||
var succeeded = compileResult.ExitCode == 0;
|
||||
|
||||
if (succeeded)
|
||||
{
|
||||
MakeRunnableIfNecessary();
|
||||
}
|
||||
|
||||
return succeeded;
|
||||
}
|
||||
|
||||
private void MakeRunnableIfNecessary()
|
||||
{
|
||||
var compilationOptions = CompilerUtil.ResolveCompilationOptions(_rootProject, _args.ConfigValue);
|
||||
|
||||
// TODO: Make this opt in via another mechanism
|
||||
var makeRunnable = compilationOptions.EmitEntryPoint.GetValueOrDefault() ||
|
||||
_rootProject.IsTestProject();
|
||||
|
||||
if (makeRunnable)
|
||||
{
|
||||
var outputPathCalculator = _rootProject.GetOutputPathCalculator(_args.OutputValue);
|
||||
var rids = new List<string>();
|
||||
if (string.IsNullOrEmpty(_args.RuntimeValue))
|
||||
{
|
||||
rids.AddRange(PlatformServices.Default.Runtime.GetAllCandidateRuntimeIdentifiers());
|
||||
}
|
||||
else
|
||||
{
|
||||
rids.Add(_args.RuntimeValue);
|
||||
}
|
||||
|
||||
var runtimeContext = ProjectContext.Create(_rootProject.ProjectDirectory, _rootProject.TargetFramework, rids);
|
||||
var executable = new Executable(runtimeContext, outputPathCalculator);
|
||||
executable.MakeCompilationOutputRunnable(_args.ConfigValue);
|
||||
}
|
||||
}
|
||||
|
||||
private static ISet<ProjectDescription> Sort(Dictionary<string, ProjectDescription> projects)
|
||||
|
|
|
@ -6,6 +6,7 @@ using System.Collections.Generic;
|
|||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Microsoft.Dotnet.Cli.Compiler.Common;
|
||||
using Microsoft.DotNet.Cli.Compiler.Common;
|
||||
using Microsoft.DotNet.Cli.Utils;
|
||||
using Microsoft.DotNet.ProjectModel;
|
||||
|
@ -315,31 +316,7 @@ namespace Microsoft.DotNet.Tools.Compiler
|
|||
{
|
||||
success &= GenerateCultureResourceAssemblies(context.ProjectFile, dependencies, outputPath);
|
||||
}
|
||||
|
||||
if (success)
|
||||
{
|
||||
// TODO: Make this opt in via another mechanism
|
||||
var makeRunnable = compilationOptions.EmitEntryPoint.GetValueOrDefault() ||
|
||||
!string.IsNullOrEmpty(context.ProjectFile.TestRunner);
|
||||
|
||||
if (makeRunnable)
|
||||
{
|
||||
var rids = new List<string>();
|
||||
if (string.IsNullOrEmpty(args.RuntimeValue))
|
||||
{
|
||||
rids.AddRange(PlatformServices.Default.Runtime.GetAllCandidateRuntimeIdentifiers());
|
||||
}
|
||||
else
|
||||
{
|
||||
rids.Add(args.RuntimeValue);
|
||||
}
|
||||
|
||||
var runtimeContext = ProjectContext.Create(context.ProjectDirectory, context.TargetFramework, rids);
|
||||
runtimeContext
|
||||
.MakeCompilationOutputRunnable(outputPath, args.ConfigValue);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return PrintSummary(diagnostics, sw, success);
|
||||
}
|
||||
|
||||
|
|
|
@ -9,6 +9,7 @@ using System.Text;
|
|||
using Microsoft.DotNet.Cli.Utils;
|
||||
using Microsoft.DotNet.Cli.Compiler.Common;
|
||||
using Microsoft.Dnx.Runtime.Common.CommandLine;
|
||||
using Microsoft.Dotnet.Cli.Compiler.Common;
|
||||
using Microsoft.DotNet.ProjectModel;
|
||||
using Microsoft.DotNet.ProjectModel.Graph;
|
||||
using NuGet.Frameworks;
|
||||
|
@ -169,12 +170,15 @@ namespace Microsoft.DotNet.Tools.Restore
|
|||
toolDescription.Path,
|
||||
Path.GetDirectoryName(toolDescription.Target.RuntimeAssemblies.First().Path),
|
||||
toolDescription.Identity.Name + FileNameSuffixes.Deps);
|
||||
|
||||
context.MakeCompilationOutputRunnable(context.ProjectDirectory, Constants.DefaultConfiguration);
|
||||
|
||||
var calculator = context.GetOutputPathCalculator(context.ProjectDirectory);
|
||||
var executable = new Executable(context, calculator);
|
||||
|
||||
executable.MakeCompilationOutputRunnable(Constants.DefaultConfiguration);
|
||||
|
||||
if (File.Exists(depsPath)) File.Delete(depsPath);
|
||||
|
||||
File.Move(Path.Combine(context.ProjectDirectory, "bin" + FileNameSuffixes.Deps), depsPath);
|
||||
File.Move(Path.Combine(calculator.GetOutputDirectoryPath(Constants.DefaultConfiguration), "bin" + FileNameSuffixes.Deps), depsPath);
|
||||
}
|
||||
|
||||
private static void RestoreToolToPath(LibraryRange tooldep, IEnumerable<string> args, string tempPath, bool quiet)
|
||||
|
|
Loading…
Reference in a new issue