diff --git a/.gitignore b/.gitignore index 1f14485237..5b47e32794 100644 --- a/.gitignore +++ b/.gitignore @@ -303,4 +303,4 @@ modules/virtual-file-explorer/app/Volo.Abp.VirtualFileExplorer.DemoApp/Logs/ /templates/app/aspnet-core/src/MyCompanyName.MyProjectName.HttpApi.HostWithIds/package-lock.json /templates/app/angular/package-lock.json /modules/client-simulation/demo/Volo.ClientSimulation.Demo/package-lock.json - +abp-build-config.json diff --git a/docs/en/CLI-BuildCommand.md b/docs/en/CLI-BuildCommand.md new file mode 100644 index 0000000000..d9620aa621 --- /dev/null +++ b/docs/en/CLI-BuildCommand.md @@ -0,0 +1,67 @@ +# Build Command + +Building a .NET project is hard when the project references a project reference outside of the solution or even from a different GIT repository. This command builds a GIT repository and it's depending repositories or a single .NET solution File. In order ```build``` command to work, its **executing directory** or passed ```--working-directory``` parameter's directory must contain one of; + +* A .NET solution file (*.sln) +* abp-build-config.json + +When the executing directory (or ```--working-directory``` parameter's directory) contains a .NET solution file, ```build``` command builds all the projects in the related solution file and all project references on building project files recursively. + +When the executing directory (or ```--working-directory``` parameter's directory) contains a ```abp-build-config.json```, ```build``` command builds all changed projects form its last build and all project references on building project files recursively. + +# Build Command Config + +```abp-build-config.json``` contains properties below; + +* ```Name```: Name of the GIT repository. This can be friendly name of your GIT repository or any other unique string for the repository. +* ```RootPath```: Root path of the repository which contains ```.git``` folder. +* ```DependingRepositories```: Depending repository list of a repository. Each depending repository item contains same fields as a repository. +* ```IgnoredDirectories```: Relative directory paths to ignore while building a GIT repository. + +A sample ```abp-build-config.json``` looks like for a Windows OS; + +````json +{ + "Name": "main-repository", + "RootPath": "D:\\GitHub\\main-repository", + "DependingRepositories": [{ + "Name": "module-repository", + "RootPath": "D:\\GitHub\\module-repository" + }], + "IgnoredDirectories": [ + "utils" + ] +} +```` + +# Build Status + +ABP CLI stores a build status file for builds using the repository friendly names and current branch names in the; + +* ```%USERPROFILE%\.abp\build\``` for Windows. +* ```$HOME/.abp/build/``` for Linux/macOS. + +and uses this file when building same repository next time and only builds affected projects and decreases the total build time. A sample build status file content looks like; + +````json +{ + "RepositoryName": "main-repository", + "BranchName": "dev", + "CommitId": "84ecde8ba275aeeb14d24a87ad46a1e941adf8ba", + "SucceedProjects": [{ + "CsProjPath": "D:\\GitHub\\main-repository\\BookStore\\BookStore.Web.csproj", + "CommitId": "84ecde8ba275aeeb14d24a87ad46a1e941adf8ba" + }], + "DependingRepositories": [{ + "RepositoryName": "module-repository", + "BranchName": "dev", + "CommitId": "0598b8e45af9507fc9ba8abf304e78fc7d434e04", + "SucceedProjects": [{ + "CsProjPath": "D:\\GitHub\\module-repository\\identity-module\Identity\\Identity.Web.csproj", + "CommitId": "0598b8e45af9507fc9ba8abf304e78fc7d434e04" + }], + "DependingRepositories": [] + }] +} +```` + diff --git a/docs/en/CLI.md b/docs/en/CLI.md index 8ea20ffd57..dd3287ce12 100644 --- a/docs/en/CLI.md +++ b/docs/en/CLI.md @@ -40,6 +40,7 @@ Here, the list of all available commands before explaining their details: * **`translate`**: Simplifies to translate localization files when you have multiple JSON [localization](Localization.md) files in a source control repository. * **`login`**: Authenticates on your computer with your [abp.io](https://abp.io/) username and password. * **`logout`**: Logouts from your computer if you've authenticated before. +* **`build`**: Builds a GIT repository and depending repositories or a single .NET solution. ### help @@ -360,3 +361,31 @@ Logs you out by removing the session token from your computer. abp logout ``` +### build + +This command builds a GIT repository and it's depending repositories or a single .NET solution File. In order ```build``` command to work, its **executing directory** or passed ```--working-directory``` parameter's directory must contain one of; + +* A .NET solution file (*.sln) +* abp-build-config.json (suggested to add this to .gitignore) + +Usage: + +````bash +abp build [options] +```` + +Example: + +``` +abp build --build-name "prod" --dotnet-build-arguments "\"--no-dependencies\"" +``` + +#### Options + +* ```--working-directory``` or ```-w```: Specifies the working directory. This option is useful when the command is executed outside of a GIT repository or when executing directory doesn't contain a .NET solution file. +* ```--build-name``` or ```-n```: Specifies a name for the build. This option is useful when same repository is used for more than one different builds. +* ```--dotnet-build-arguments``` or ```-a```: Arguments to pass ```dotnet build``` when building project files. This parameter must be passed like ```"\"{params}\""``` . +* ```--force``` or ```-f```: Forces to build projects even they are not changed from the last successful build. + +For more details, see [build command documentation](CLI-BuildCommand.md). + diff --git a/framework/src/Volo.Abp.Cli.Core/Volo.Abp.Cli.Core.csproj b/framework/src/Volo.Abp.Cli.Core/Volo.Abp.Cli.Core.csproj index 40eecfd06a..2dfd66f4ab 100644 --- a/framework/src/Volo.Abp.Cli.Core/Volo.Abp.Cli.Core.csproj +++ b/framework/src/Volo.Abp.Cli.Core/Volo.Abp.Cli.Core.csproj @@ -22,6 +22,7 @@ + diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/AbpCliCoreModule.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/AbpCliCoreModule.cs index a79b52b4f2..29238a3e35 100644 --- a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/AbpCliCoreModule.cs +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/AbpCliCoreModule.cs @@ -36,6 +36,7 @@ namespace Volo.Abp.Cli options.Commands["switch-to-stable"] = typeof(SwitchToStableCommand); options.Commands["switch-to-nightly"] = typeof(SwitchToNightlyCommand); options.Commands["translate"] = typeof(TranslateCommand); + options.Commands["build"] = typeof(BuildCommand); }); } } diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/DefaultBuildProjectListSorter.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/DefaultBuildProjectListSorter.cs new file mode 100644 index 0000000000..6df892b472 --- /dev/null +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/DefaultBuildProjectListSorter.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Volo.Abp.DependencyInjection; + +namespace Volo.Abp.Cli.Build +{ + public class DefaultBuildProjectListSorter : IBuildProjectListSorter, ITransientDependency + { + public List SortByDependencies( + List source, + IEqualityComparer comparer = null) + { + /* See: http://www.codeproject.com/Articles/869059/Topological-sorting-in-Csharp + * http://en.wikipedia.org/wiki/Topological_sorting + */ + + var sorted = new List(); + var visited = new Dictionary(comparer); + + foreach (var item in source) + { + SortByDependenciesVisit(source, item, sorted, visited); + } + + return sorted; + } + + private void SortByDependenciesVisit( + List source, + DotNetProjectInfo item, + List sorted, + Dictionary visited) + { + bool inProcess; + var alreadyVisited = visited.TryGetValue(item, out inProcess); + + if (alreadyVisited) + { + if (inProcess) + { + throw new ArgumentException("Cyclic dependency found! Item: " + item); + } + } + else + { + visited[item] = true; + + var dependencies = item.Dependencies; + if (dependencies != null) + { + foreach (var dependency in dependencies) + { + var dependencyItem = source.FirstOrDefault(e => e.CsProjPath == dependency.CsProjPath); + if (dependencyItem != null) + { + SortByDependenciesVisit(source, dependencyItem, sorted, visited); + } + } + } + + visited[item] = false; + sorted.Add(item); + } + } + } +} \ No newline at end of file diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/DefaultBuildStatusGenerator.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/DefaultBuildStatusGenerator.cs new file mode 100644 index 0000000000..e57c6837c0 --- /dev/null +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/DefaultBuildStatusGenerator.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using LibGit2Sharp; +using Volo.Abp.DependencyInjection; + +namespace Volo.Abp.Cli.Build +{ + public class DefaultBuildStatusGenerator : IBuildStatusGenerator, ITransientDependency + { + private readonly IGitRepositoryHelper _gitRepositoryHelper; + + public DefaultBuildStatusGenerator(IGitRepositoryHelper gitRepositoryHelper) + { + _gitRepositoryHelper = gitRepositoryHelper; + } + + public GitRepositoryBuildStatus Generate( + DotNetProjectBuildConfig buildConfig, + List changedProjects, + List buildSucceededProjects) + { + var lastCommitId = _gitRepositoryHelper.GetLastCommitId(buildConfig.GitRepository); + var repoFriendlyName = _gitRepositoryHelper.GetFriendlyName(buildConfig.GitRepository); + + var status = new GitRepositoryBuildStatus( + buildConfig.GitRepository.Name, + repoFriendlyName + ); + + if (ShouldUpdateRepositoryCommitId(buildConfig, changedProjects, buildSucceededProjects)) + { + status.CommitId = lastCommitId; + } + + status.SucceedProjects = changedProjects.Where(p => + p.RepositoryName == buildConfig.GitRepository.Name && + buildSucceededProjects.Contains(p.CsProjPath) + ) + .Select(e => new DotNetProjectBuildStatus + { + CsProjPath = e.CsProjPath, + CommitId = lastCommitId + }).ToList(); + + foreach (var dependingRepository in buildConfig.GitRepository.DependingRepositories) + { + GenerateBuildStatusInternal( + buildConfig, + dependingRepository, + changedProjects, + buildSucceededProjects, + status + ); + } + + return status; + } + + private bool ShouldUpdateRepositoryCommitId( + DotNetProjectBuildConfig buildConfig, + List changedProjects, + List buildSucceededProjects) + { + if (!buildConfig.SlFilePath.IsNullOrEmpty()) + { + return false; + } + + if (changedProjects.Count == 0 || buildSucceededProjects.Count == 0) + { + return false; + } + + return changedProjects.Count == buildSucceededProjects.Count; + } + + private void GenerateBuildStatusInternal( + DotNetProjectBuildConfig buildConfig, + GitRepository gitRepository, + List changedProjects, + List buildSucceededProjects, + GitRepositoryBuildStatus status) + { + var lastCommitId = _gitRepositoryHelper.GetLastCommitId(gitRepository); + var repoFriendlyName = _gitRepositoryHelper.GetFriendlyName(gitRepository); + + var dependingRepositoryStatus = new GitRepositoryBuildStatus( + gitRepository.Name, + repoFriendlyName + ); + + if (ShouldUpdateRepositoryCommitId(buildConfig, changedProjects, buildSucceededProjects)) + { + dependingRepositoryStatus.CommitId = lastCommitId; + } + + dependingRepositoryStatus.SucceedProjects = changedProjects.Where(p => + p.RepositoryName == gitRepository.Name && + buildSucceededProjects.Contains(p.CsProjPath) + ) + .Select(e => new DotNetProjectBuildStatus() + { + CsProjPath = e.CsProjPath, + CommitId = lastCommitId + }).ToList(); + + foreach (var dependingRepository in gitRepository.DependingRepositories) + { + GenerateBuildStatusInternal( + buildConfig, + dependingRepository, + changedProjects, + buildSucceededProjects, + dependingRepositoryStatus + ); + } + + status.DependingRepositories.Add(dependingRepositoryStatus); + } + } +} diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/DefaultChangedProjectFinder.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/DefaultChangedProjectFinder.cs new file mode 100644 index 0000000000..e866c425b5 --- /dev/null +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/DefaultChangedProjectFinder.cs @@ -0,0 +1,352 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Xml.Linq; +using LibGit2Sharp; +using Volo.Abp.DependencyInjection; + +namespace Volo.Abp.Cli.Build +{ + public class DefaultChangedProjectFinder : IChangedProjectFinder, ITransientDependency + { + private readonly IRepositoryBuildStatusStore _repositoryBuildStatusStore; + private readonly IGitRepositoryHelper _gitRepositoryHelper; + + private readonly IDotNetProjectDependencyFiller _dotNetProjectDependencyFiller; + private readonly IBuildProjectListSorter _buildProjectListSorter; + + private readonly List _changeDetectionFileExtensions = new List + { + ".cs", + ".csproj", + ".cshtml" + }; + + public DefaultChangedProjectFinder( + IRepositoryBuildStatusStore repositoryBuildStatusStore, + IGitRepositoryHelper gitRepositoryHelper, + IDotNetProjectDependencyFiller dotNetProjectDependencyFiller, + IBuildProjectListSorter buildProjectListSorter) + { + _repositoryBuildStatusStore = repositoryBuildStatusStore; + _gitRepositoryHelper = gitRepositoryHelper; + _dotNetProjectDependencyFiller = dotNetProjectDependencyFiller; + _buildProjectListSorter = buildProjectListSorter; + } + + public List Find(DotNetProjectBuildConfig buildConfig) + { + if (!buildConfig.SlFilePath.IsNullOrEmpty()) + { + return FindBySlnFile(buildConfig); + } + + return FindByRepository(buildConfig); + } + + /// + /// Returns list of projects in a repository and its depending repositories sorted by dependencies + /// + /// + /// + private List FindAllProjects(DotNetProjectBuildConfig buildConfig) + { + var projects = new List(); + + AddProjectsOfRepository(buildConfig.GitRepository, projects); + + _dotNetProjectDependencyFiller.Fill(projects); + + var allSortedProjectList = _buildProjectListSorter.SortByDependencies( + projects, + new DotNetProjectInfoEqualityComparer() + ); + + FilterIgnoredDirectories(allSortedProjectList, buildConfig.GitRepository); + + return allSortedProjectList; + } + + private void FilterIgnoredDirectories(List projects, GitRepository gitRepository) + { + foreach (var ignoredDirectory in gitRepository.IgnoredDirectories) + { + projects = projects.Where(e => + !e.CsProjPath.StartsWith(Path.Combine(gitRepository.RootPath, ignoredDirectory))) + .ToList(); + } + + foreach (var dependingRepository in gitRepository.DependingRepositories) + { + FilterIgnoredDirectories(projects, dependingRepository); + } + } + + private void AddProjectsOfRepository(GitRepository gitRepository, List projects) + { + var allCsProjFiles = Directory.GetFiles( + gitRepository.RootPath, + "*.csproj", + SearchOption.AllDirectories + ).ToList(); + + projects.AddRange( + allCsProjFiles.Select(csProjPath => new DotNetProjectInfo(gitRepository.Name, csProjPath, false)) + ); + + foreach (var dependingRepository in gitRepository.DependingRepositories) + { + AddProjectsOfRepository(dependingRepository, projects); + } + } + + private List FindByRepository(DotNetProjectBuildConfig buildConfig) + { + var gitRepositoryBuildStatus = _repositoryBuildStatusStore.Get( + buildConfig.BuildName, + buildConfig.GitRepository + ); + + var allSortedProjectList = FindAllProjects(buildConfig); + + MarkProjectsForBuild( + buildConfig.GitRepository, + gitRepositoryBuildStatus, + buildConfig.ForceBuild, + allSortedProjectList + ); + + return allSortedProjectList.Where(e => e.ShouldBuild).ToList(); + } + + private void MarkProjectsForBuild( + GitRepository repository, + GitRepositoryBuildStatus repositoryBuildStatus, + bool forceBuild, + List allProjectList) + { + if (forceBuild || repositoryBuildStatus == null || repositoryBuildStatus.CommitId.IsNullOrEmpty()) + { + // Mark all projects for build + allProjectList.ForEach(e => e.ShouldBuild = true); + } + else + { + MarkChangedProjectsForBuild( + repository, + repositoryBuildStatus, + allProjectList + ); + } + + if (!repository.DependingRepositories.Any()) + { + return; + } + + foreach (var dependingRepository in repository.DependingRepositories) + { + var dependingRepositoryBuildStatus = repositoryBuildStatus?.GetChild(dependingRepository.Name); + MarkProjectsForBuild( + dependingRepository, + dependingRepositoryBuildStatus, + forceBuild, + allProjectList + ); + } + } + + private void MarkChangedProjectsForBuild( + GitRepository repository, + GitRepositoryBuildStatus status, + List allProjectList) + { + using (var repo = new Repository(string.Concat(repository.RootPath, @"\.git"))) + { + var firstCommit = status.CommitId.IsNullOrEmpty() + ? null + : repo.Lookup(status.CommitId); + + var repoDifferences = repo.Diff.Compare(firstCommit?.Tree, repo.Head.Tip.Tree); + + var fileExtensionPredicate = PredicateBuilder.New(true); + + foreach (var changeDetectionFileExtension in _changeDetectionFileExtensions) + { + fileExtensionPredicate = fileExtensionPredicate.Or( + e => e.Path.EndsWith(changeDetectionFileExtension) + ); + } + + var files = repoDifferences + .Where(fileExtensionPredicate) + .Where(e => e.Status != ChangeKind.Deleted) + .Select(e => e) + .ToList(); + + var affectedCsProjFiles = FindAffectedCsProjFiles(repository.RootPath, files); + var lastCommitId = _gitRepositoryHelper.GetLastCommitId(repository); + + foreach (var file in affectedCsProjFiles) + { + var csProjPath = Path.Combine(repository.RootPath, file); + if (status.SucceedProjects.Any(p => p.CsProjPath == csProjPath && p.CommitId == lastCommitId)) + { + continue; + } + + // Filter ignored directories + var isIgnored = repository.IgnoredDirectories.Any(ignoredDirectory => + csProjPath.StartsWith(Path.Combine(repository.RootPath, ignoredDirectory))); + if (isIgnored) + { + continue; + } + + allProjectList.MarkForBuild(repository.Name, csProjPath); + AddDependingProjectsToList(repository.Name, csProjPath, allProjectList); + } + } + } + + private List FindBySlnFile(DotNetProjectBuildConfig buildConfig) + { + var allProjectList = FindAllProjects(buildConfig); + + var slFine = new FileInfo(buildConfig.SlFilePath); + var csProjFiles = slFine.Directory.GetFiles( + "*.csproj", + SearchOption.AllDirectories + ).Select(e => e.FullName) + .ToList(); + + foreach (var csProjFile in csProjFiles) + { + MarkDependantProjectsForBuild(buildConfig.GitRepository, csProjFile, allProjectList); + } + + return _buildProjectListSorter.SortByDependencies( + allProjectList, + new DotNetProjectInfoEqualityComparer() + ).Where(e => e.ShouldBuild).ToList(); + } + + private void MarkDependantProjectsForBuild( + GitRepository gitRepository, + string csProjFilePath, + List allProjectList) + { + var repositoryName = gitRepository.FindRepositoryOf(csProjFilePath); + var project = new DotNetProjectInfo(repositoryName, csProjFilePath, true); + + if (allProjectList.IsMarkedForBuild(repositoryName, csProjFilePath)) + { + return; + } + + allProjectList.MarkForBuild(project); + AddProjectDependencies(gitRepository, project, allProjectList); + } + + private void AddProjectDependencies( + GitRepository gitRepository, + DotNetProjectInfo project, + List allProjectList) + { + var projectInfo = allProjectList.FirstOrDefault(e => e.CsProjPath == project.CsProjPath); + if (projectInfo == null) + { + return; + } + + var dependencies = projectInfo.Dependencies; + + foreach (var dependency in dependencies) + { + if (allProjectList.IsMarkedForBuild(dependency.RepositoryName, dependency.CsProjPath)) + { + continue; + } + + allProjectList.MarkForBuild(dependency.RepositoryName, dependency.CsProjPath); + + AddProjectDependencies(gitRepository, dependency, allProjectList); + } + } + + private void AddDependingProjectsToList( + string repositoryName, + string csProjPath, + List allProjectList) + { + var dependingProjects = allProjectList.Where( + e => e.Dependencies.Any(d => d.RepositoryName == repositoryName && d.CsProjPath == csProjPath) + ).Select(e => new DotNetProjectInfo(e.RepositoryName, e.CsProjPath, true)).ToList(); + + if (!dependingProjects.Any()) + { + return; + } + + foreach (var dependingProject in dependingProjects) + { + if (allProjectList.IsMarkedForBuild(dependingProject)) + { + continue; + } + + allProjectList.MarkForBuild(dependingProject); + AddDependingProjectsToList(dependingProject.RepositoryName, dependingProject.CsProjPath, + allProjectList); + } + } + + private List FindAffectedCsProjFiles(string repositoryPath, List files) + { + var affectedProjectFiles = new List(); + foreach (var file in files) + { + var filePath = Path.Combine(repositoryPath, file.Path); + if (filePath.EndsWith(".csproj")) + { + affectedProjectFiles.Add(filePath); + } + + if (!filePath.EndsWith(".cs")) + { + continue; + } + + var classFile = new FileInfo(filePath); + var csProjPath = FindBelongingProjectPathOfClass(classFile.Directory?.FullName); + if (csProjPath.IsNullOrEmpty() || affectedProjectFiles.Contains(csProjPath)) + { + continue; + } + + affectedProjectFiles.Add(csProjPath); + } + + return affectedProjectFiles; + } + + private string FindBelongingProjectPathOfClass(string directoryPath) + { + var files = Directory.GetFiles(directoryPath, "*.csproj", SearchOption.TopDirectoryOnly); + if (files.Length == 1) + { + return files.First(); + } + + var directoryInfo = new DirectoryInfo(directoryPath); + + if (directoryInfo.Parent == null) + { + return null; + } + + return FindBelongingProjectPathOfClass(directoryInfo.Parent.FullName); + } + } +} diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/DefaultDotNetProjectBuilder.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/DefaultDotNetProjectBuilder.cs new file mode 100644 index 0000000000..65749fd45c --- /dev/null +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/DefaultDotNetProjectBuilder.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using Volo.Abp.Cli.Utils; +using Volo.Abp.DependencyInjection; + +namespace Volo.Abp.Cli.Build +{ + public class DefaultDotNetProjectBuilder : IDotNetProjectBuilder, ITransientDependency + { + public List Build(List projects, string arguments) + { + var builtProjects = new ConcurrentBag(); + var totalProjectCountToBuild = projects.Count; + var buildingProjectIndex = 0; + + try + { + foreach (var project in projects) + { + if (builtProjects.Contains(project.CsProjPath)) + { + continue; + } + + buildingProjectIndex++; + + Console.WriteLine( + "Building....: " + " (" + buildingProjectIndex + "/" + + totalProjectCountToBuild + ")" + project.CsProjPath + ); + + BuildInternal(project, arguments, builtProjects); + } + } + catch (Exception e) + { + Console.WriteLine(e); + } + + return builtProjects.ToList(); + } + + private void BuildInternal(DotNetProjectInfo project, string arguments, ConcurrentBag builtProjects) + { + var buildArguments = arguments.TrimStart('"').TrimEnd('"'); + Console.WriteLine("Executing...: dotnet build " + project.CsProjPath + " " + buildArguments); + + var output = CmdHelper.RunCmdAndGetOutput( + "dotnet build " + project.CsProjPath + " " + buildArguments, + out int buildStatus + ); + + if (buildStatus == 0) + { + builtProjects.Add(project.CsProjPath); + WriteOutput(output, ConsoleColor.Green); + } + else + { + WriteOutput(output, ConsoleColor.Red); + Console.WriteLine("Build failed for :" + project.CsProjPath); + throw new Exception("Build failed!"); + } + } + + private void WriteOutput(string text, ConsoleColor color) + { + var currentConsoleColor = Console.ForegroundColor; + Console.ForegroundColor = color; + Console.WriteLine(text); + Console.ForegroundColor = currentConsoleColor; + } + } +} diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/DotNetProjectBuildConfig.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/DotNetProjectBuildConfig.cs new file mode 100644 index 0000000000..9a9da0766a --- /dev/null +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/DotNetProjectBuildConfig.cs @@ -0,0 +1,13 @@ +namespace Volo.Abp.Cli.Build +{ + public class DotNetProjectBuildConfig + { + public string BuildName { get; set; } + + public string SlFilePath { get; set; } + + public GitRepository GitRepository { get; set; } + + public bool ForceBuild { get; set; } + } +} diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/DotNetProjectBuildStatus.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/DotNetProjectBuildStatus.cs new file mode 100644 index 0000000000..10092777c2 --- /dev/null +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/DotNetProjectBuildStatus.cs @@ -0,0 +1,9 @@ +namespace Volo.Abp.Cli.Build +{ + public class DotNetProjectBuildStatus + { + public string CsProjPath { get; set; } + + public string CommitId { get; set; } + } +} \ No newline at end of file diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/DotNetProjectDependencyFiller.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/DotNetProjectDependencyFiller.cs new file mode 100644 index 0000000000..b9c883a45d --- /dev/null +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/DotNetProjectDependencyFiller.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Xml.Linq; +using Microsoft.Extensions.Logging; +using Volo.Abp.DependencyInjection; + +namespace Volo.Abp.Cli.Build +{ + public class DotNetProjectDependencyFiller : IDotNetProjectDependencyFiller, ITransientDependency + { + public void Fill(List projects) + { + foreach (var project in projects) + { + FillProjectDependencies(project); + } + } + + private void FillProjectDependencies(DotNetProjectInfo project) + { + var projectNode = XElement.Load(project.CsProjPath); + var referenceNodes = projectNode.Descendants("ItemGroup").Descendants("ProjectReference"); + + foreach (var referenceNode in referenceNodes) + { + if (referenceNode.Attribute("Include") == null) + { + continue; + } + + var relativePath = referenceNode.Attribute("Include").Value; + var file = new FileInfo(project.CsProjPath); + var referenceProjectInfo = new FileInfo(Path.Combine(file.Directory.FullName, relativePath)); + + var referenceProject = new DotNetProjectInfo(project.RepositoryName, referenceProjectInfo.FullName, false); + project.Dependencies.Add(referenceProject); + } + } + } +} diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/DotNetProjectInfo.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/DotNetProjectInfo.cs new file mode 100644 index 0000000000..3a8fe825ca --- /dev/null +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/DotNetProjectInfo.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; + +namespace Volo.Abp.Cli.Build +{ + [Serializable] + public class DotNetProjectInfo + { + public string RepositoryName { get; set; } + + public string CsProjPath { get; set; } + + public bool ShouldBuild { get; set; } + + public List Dependencies { get; set; } + + public DotNetProjectInfo(string repositoryName, string csProjPath, bool shouldBuild) + { + RepositoryName = repositoryName; + CsProjPath = csProjPath; + ShouldBuild = shouldBuild; + Dependencies = new List(); + } + } +} diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/DotNetProjectInfoEqualityComparer.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/DotNetProjectInfoEqualityComparer.cs new file mode 100644 index 0000000000..c6f8e5dd53 --- /dev/null +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/DotNetProjectInfoEqualityComparer.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; + +namespace Volo.Abp.Cli.Build +{ + public class DotNetProjectInfoEqualityComparer : EqualityComparer + { + public override bool Equals(DotNetProjectInfo x, DotNetProjectInfo y) + { + return (x == null && y == null) || (x != null && y != null && x.CsProjPath == y.CsProjPath); + } + + public override int GetHashCode(DotNetProjectInfo obj) + { + return obj == null ? 0 : obj.CsProjPath.GetHashCode(); + } + } +} \ No newline at end of file diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/DotNetProjectInfoExtensions.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/DotNetProjectInfoExtensions.cs new file mode 100644 index 0000000000..ace85e5257 --- /dev/null +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/DotNetProjectInfoExtensions.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; +using System.Linq; + +namespace Volo.Abp.Cli.Build +{ + public static class DotNetProjectInfoExtensions + { + public static void MarkForBuild(this List projects, string repositoryName, string csProjPath) + { + var project = projects.FirstOrDefault(e => + e.RepositoryName == repositoryName && e.CsProjPath == csProjPath + ); + + if (project == null) + { + return; + } + + project.ShouldBuild = true; + } + + public static void MarkForBuild(this List projects, DotNetProjectInfo project) + { + projects.MarkForBuild(project.RepositoryName, project.CsProjPath); + } + + public static bool IsMarkedForBuild(this List projects, string repositoryName, + string csProjPath) + { + var project = projects.FirstOrDefault(e => + e.RepositoryName == repositoryName && e.CsProjPath == csProjPath + ); + + if (project == null) + { + return false; + } + + return project.ShouldBuild; + } + + public static bool IsMarkedForBuild(this List projects, DotNetProjectInfo project) + { + return projects.IsMarkedForBuild(project.RepositoryName, project.CsProjPath); + } + } +} diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/FileSystemDotNetProjectBuildConfigReader.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/FileSystemDotNetProjectBuildConfigReader.cs new file mode 100644 index 0000000000..5aed3d0941 --- /dev/null +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/FileSystemDotNetProjectBuildConfigReader.cs @@ -0,0 +1,93 @@ +using System; +using System.IO; +using System.Linq; +using LibGit2Sharp; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Json; + +namespace Volo.Abp.Cli.Build +{ + public class FileSystemDotNetProjectBuildConfigReader : IDotNetProjectBuildConfigReader, ITransientDependency + { + private readonly IJsonSerializer _jsonSerializer; + private string _buildConfigName = "abp-build-config.json"; + + public FileSystemDotNetProjectBuildConfigReader(IJsonSerializer jsonSerializer) + { + _jsonSerializer = jsonSerializer; + } + + public DotNetProjectBuildConfig Read(string directoryPath) + { + var buildConfig = new DotNetProjectBuildConfig(); + var solutionFiles = Directory.GetFiles(directoryPath, "*.sln", SearchOption.TopDirectoryOnly); + if (solutionFiles.Length == 1) + { + buildConfig.SlFilePath = solutionFiles.First(); + var configFile = GetClosestFile(directoryPath, _buildConfigName); + var configFileContent = File.ReadAllText(configFile); + buildConfig.GitRepository = _jsonSerializer.Deserialize(configFileContent); + + SetBranchNames(buildConfig.GitRepository); + + return buildConfig; + } + + var configFiles = Directory.GetFiles(directoryPath, _buildConfigName, SearchOption.TopDirectoryOnly); + if (configFiles.Length == 1) + { + var configFile = configFiles.First(); + var configFileContent = File.ReadAllText(configFile); + buildConfig.GitRepository = _jsonSerializer.Deserialize(configFileContent); + + SetBranchNames(buildConfig.GitRepository); + + return buildConfig; + } + + Console.WriteLine( + "There are more than 1 config (abp-build-config.json) file in the directory!" + ); + + throw new Exception("There is no solution file (*.sln) or " + _buildConfigName + " in the working directory !"); + } + + private void SetBranchNames(GitRepository gitRepository) + { + using (var repo = new Repository(string.Concat(gitRepository.RootPath, @"\.git"))) + { + gitRepository.BranchName = repo.Head.FriendlyName; + } + + foreach (var dependingRepository in gitRepository.DependingRepositories) + { + SetBranchNames(dependingRepository); + } + } + + private string GetClosestFile(string directoryPath, string fileName) + { + var directory = new DirectoryInfo(directoryPath); + var files = directory.GetFiles(fileName, SearchOption.TopDirectoryOnly); + if (files.Any() && files.Length == 1) + { + return files.First().FullName; + } + + do + { + directory = directory.Parent; + if (directory == null) + { + return string.Empty; + } + + files = directory.GetFiles(fileName, SearchOption.TopDirectoryOnly); + if (files.Any() && files.Length == 1) + { + return files.First().FullName; + } + } while (true); + } + } +} diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/FileSystemRepositoryBuildStatusStore.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/FileSystemRepositoryBuildStatusStore.cs new file mode 100644 index 0000000000..0f6b2f19a7 --- /dev/null +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/FileSystemRepositoryBuildStatusStore.cs @@ -0,0 +1,61 @@ +using System.IO; +using Newtonsoft.Json; +using Volo.Abp.DependencyInjection; +using Volo.Abp.IO; + +namespace Volo.Abp.Cli.Build +{ + public class FileSystemRepositoryBuildStatusStore : IRepositoryBuildStatusStore, ITransientDependency + { + public GitRepositoryBuildStatus Get(string buildNamePrefix, GitRepository repository) + { + if (!Directory.Exists(CliPaths.Build)) + { + Directory.CreateDirectory(CliPaths.Build); + } + + var buildStatusFile = Path.Combine(CliPaths.Build, repository.GetUniqueName(buildNamePrefix)) + + ".json"; + + if (!File.Exists(buildStatusFile)) + { + return null; + } + + var buildStatusText = File.ReadAllText(buildStatusFile); + return JsonConvert.DeserializeObject(buildStatusText); + } + + public void Set(string buildNamePrefix, GitRepository repository, GitRepositoryBuildStatus status) + { + var existingRepositoryStatus = Get(buildNamePrefix, repository); + + var buildStatusFile = Path.Combine( + CliPaths.Build, + status.GetUniqueName(buildNamePrefix) + ) + ".json"; + + if (File.Exists(buildStatusFile)) + { + FileHelper.DeleteIfExists(buildStatusFile); + } + + if (existingRepositoryStatus != null) + { + existingRepositoryStatus.MergeWith(status); + + using (var file = File.CreateText(buildStatusFile)) + { + new JsonSerializer {Formatting = Formatting.Indented}.Serialize(file, existingRepositoryStatus); + } + } + else + { + using (var file = File.CreateText(buildStatusFile)) + { + new JsonSerializer {Formatting = Formatting.Indented}.Serialize(file, status); + } + } + } + } +} diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/GitRepository.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/GitRepository.cs new file mode 100644 index 0000000000..eb1acafd82 --- /dev/null +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/GitRepository.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; + +namespace Volo.Abp.Cli.Build +{ + /// + /// Represents a source code repository + /// + public class GitRepository + { + /// + /// Name of the repository + /// + public string Name { get; set; } + + /// + /// Branch of the repository + /// + public string BranchName { get; set; } + + /// + /// Root path of the repository which contains .git folder + /// + public string RootPath { get; set; } + + public List DependingRepositories { get; set; } + + public List IgnoredDirectories { get; set; } + + public GitRepository(string name, string branchName, string rootPath) + { + Name = name; + BranchName = branchName; + RootPath = rootPath; + DependingRepositories = new List(); + IgnoredDirectories = new List(); + } + + public string GetUniqueName(string prefix) + { + var name = Name + "_" + BranchName; + foreach (var dependingRepository in DependingRepositories) + { + AddToUniqueName(dependingRepository, name); + } + + return (prefix.IsNullOrEmpty() ? "" : prefix + "_") + name.ToMd5(); + } + + private void AddToUniqueName(GitRepository gitRepository, string name) + { + name += "_" + gitRepository.Name + "_" + gitRepository.BranchName; + + foreach (var dependingRepository in gitRepository.DependingRepositories) + { + AddToUniqueName(dependingRepository, name); + } + } + + public string FindRepositoryOf(string csProjFilePath) + { + if (csProjFilePath.StartsWith(RootPath)) + { + return Name; + } + + foreach (var dependingRepository in DependingRepositories) + { + var name = FindRepositoryOfInternal(dependingRepository, csProjFilePath); + if (!string.IsNullOrEmpty(name)) + { + return name; + } + } + + return null; + } + + private string FindRepositoryOfInternal(GitRepository repository, string csProjFilePath) + { + if (csProjFilePath.StartsWith(repository.RootPath)) + { + return repository.Name; + } + + foreach (var dependingRepository in repository.DependingRepositories) + { + return FindRepositoryOfInternal(dependingRepository, csProjFilePath); + } + + return null; + } + } +} diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/GitRepositoryBuildStatus.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/GitRepositoryBuildStatus.cs new file mode 100644 index 0000000000..1a60a04068 --- /dev/null +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/GitRepositoryBuildStatus.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Volo.Abp.Cli.Build +{ + public class GitRepositoryBuildStatus + { + /// + /// Name of the repository + /// + public string RepositoryName { get; set; } + + /// + /// Branch of the repository + /// + public string BranchName { get; set; } + + /// + /// Last succeeded commitId of the repository + /// + public string CommitId { get; set; } + + /// + /// Build succeeded projects of this repository + /// + public List SucceedProjects { get; set; } + + /// + /// Build status of depending repositories + /// + public List DependingRepositories { get; set; } + + public GitRepositoryBuildStatus(string repositoryName, string branchName) + { + RepositoryName = repositoryName; + BranchName = branchName; + SucceedProjects = new List(); + DependingRepositories = new List(); + } + + public GitRepositoryBuildStatus GetSelfOrChild(string repositoryName) + { + if (RepositoryName == repositoryName) + { + return this; + } + + return GetChild(repositoryName); + } + + public GitRepositoryBuildStatus GetChild(string repositoryName) + { + foreach (var dependingRepository in DependingRepositories) + { + var child = GetChildInternal(dependingRepository, repositoryName); + if (child != null) + { + return child; + } + } + + return null; + } + + public string GetUniqueName(string prefix) + { + var name = RepositoryName + "_" + BranchName; + foreach (var dependingRepository in DependingRepositories) + { + AddToUniqueName(dependingRepository, name); + } + + return (prefix.IsNullOrEmpty() ? "" : prefix + "_") + name.ToMd5(); + } + + public void AddOrUpdateProjectStatus(DotNetProjectBuildStatus status) + { + var existingProjectStatus = SucceedProjects.FirstOrDefault(p => p.CsProjPath == status.CsProjPath); + if (existingProjectStatus != null) + { + existingProjectStatus.CommitId = status.CommitId; + } + else + { + SucceedProjects.Add(status); + } + } + + public void MergeWith(GitRepositoryBuildStatus newBuildStatus) + { + if (!newBuildStatus.CommitId.IsNullOrEmpty()) + { + CommitId = newBuildStatus.CommitId; + } + + foreach (var succeedProject in newBuildStatus.SucceedProjects) + { + AddOrUpdateProjectStatus(succeedProject); + } + + foreach (var dependingRepositoryBuildStatus in newBuildStatus.DependingRepositories) + { + var existingDependingRepositoryBuildStatus = GetChild(dependingRepositoryBuildStatus.RepositoryName); + var newDependingRepositoryBuildStatus = newBuildStatus.GetChild( + dependingRepositoryBuildStatus.RepositoryName + ); + + existingDependingRepositoryBuildStatus.MergeWith(newDependingRepositoryBuildStatus); + } + } + + private GitRepositoryBuildStatus GetChildInternal(GitRepositoryBuildStatus repositoryBuildStatus, + string repositoryName) + { + if (repositoryBuildStatus.RepositoryName == repositoryName) + { + return repositoryBuildStatus; + } + + foreach (var dependingRepository in repositoryBuildStatus.DependingRepositories) + { + var child = GetChildInternal(dependingRepository, repositoryName); + if (child != null) + { + return child; + } + } + + return null; + } + + private void AddToUniqueName(GitRepositoryBuildStatus gitRepository, string name) + { + name += "_" + gitRepository.RepositoryName + "_" + gitRepository.BranchName; + + foreach (var dependingRepository in gitRepository.DependingRepositories) + { + AddToUniqueName(dependingRepository, name); + } + } + } +} diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/GitRepositoryHelper.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/GitRepositoryHelper.cs new file mode 100644 index 0000000000..51c84f0f61 --- /dev/null +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/GitRepositoryHelper.cs @@ -0,0 +1,24 @@ +using LibGit2Sharp; +using Volo.Abp.DependencyInjection; + +namespace Volo.Abp.Cli.Build +{ + public class GitRepositoryHelper : IGitRepositoryHelper, ITransientDependency + { + public string GetLastCommitId(GitRepository repository) + { + using (var repo = new Repository(string.Concat(repository.RootPath, @"\.git"))) + { + return repo.Head.Tip.Id.ToString(); + } + } + + public string GetFriendlyName(GitRepository repository) + { + using (var repo = new Repository(string.Concat(repository.RootPath, @"\.git"))) + { + return repo.Head.FriendlyName; + } + } + } +} \ No newline at end of file diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/IBuildProjectListSorter.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/IBuildProjectListSorter.cs new file mode 100644 index 0000000000..ed97ca27c8 --- /dev/null +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/IBuildProjectListSorter.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace Volo.Abp.Cli.Build +{ + public interface IBuildProjectListSorter + { + List SortByDependencies( + List source, + IEqualityComparer comparer = null); + } +} diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/IBuildStatusGenerator.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/IBuildStatusGenerator.cs new file mode 100644 index 0000000000..f9cf0b8254 --- /dev/null +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/IBuildStatusGenerator.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; + +namespace Volo.Abp.Cli.Build +{ + public interface IBuildStatusGenerator + { + GitRepositoryBuildStatus Generate( + DotNetProjectBuildConfig buildConfig, + List changedProjects, + List buildSucceededProjects + ); + } +} diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/IChangedProjectFinder.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/IChangedProjectFinder.cs new file mode 100644 index 0000000000..b381a073b4 --- /dev/null +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/IChangedProjectFinder.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace Volo.Abp.Cli.Build +{ + public interface IChangedProjectFinder + { + List Find(DotNetProjectBuildConfig buildConfig); + } +} diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/IDotNetProjectBuildConfigReader.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/IDotNetProjectBuildConfigReader.cs new file mode 100644 index 0000000000..54dd2bd5ac --- /dev/null +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/IDotNetProjectBuildConfigReader.cs @@ -0,0 +1,7 @@ +namespace Volo.Abp.Cli.Build +{ + public interface IDotNetProjectBuildConfigReader + { + DotNetProjectBuildConfig Read(string directoryPath); + } +} diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/IDotNetProjectBuilder.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/IDotNetProjectBuilder.cs new file mode 100644 index 0000000000..02f753c325 --- /dev/null +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/IDotNetProjectBuilder.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace Volo.Abp.Cli.Build +{ + public interface IDotNetProjectBuilder + { + List Build(List projects, string arguments); + } +} diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/IDotNetProjectDependencyFiller.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/IDotNetProjectDependencyFiller.cs new file mode 100644 index 0000000000..cc1ba65bc7 --- /dev/null +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/IDotNetProjectDependencyFiller.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace Volo.Abp.Cli.Build +{ + public interface IDotNetProjectDependencyFiller + { + void Fill(List projects); + } +} diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/IGitRepositoryHelper.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/IGitRepositoryHelper.cs new file mode 100644 index 0000000000..a1402c7dda --- /dev/null +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/IGitRepositoryHelper.cs @@ -0,0 +1,9 @@ +namespace Volo.Abp.Cli.Build +{ + public interface IGitRepositoryHelper + { + string GetLastCommitId(GitRepository repository); + + string GetFriendlyName(GitRepository repository); + } +} diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/IRepositoryBuildStatusStore.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/IRepositoryBuildStatusStore.cs new file mode 100644 index 0000000000..99be400ec8 --- /dev/null +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Build/IRepositoryBuildStatusStore.cs @@ -0,0 +1,9 @@ +namespace Volo.Abp.Cli.Build +{ + public interface IRepositoryBuildStatusStore + { + GitRepositoryBuildStatus Get(string buildNamePrefix, GitRepository repository); + + void Set(string buildNamePrefix, GitRepository repository, GitRepositoryBuildStatus status); + } +} diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/CliPaths.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/CliPaths.cs index eb6339b9da..2b422446ca 100644 --- a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/CliPaths.cs +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/CliPaths.cs @@ -9,7 +9,8 @@ namespace Volo.Abp.Cli public static string Log => Path.Combine(AbpRootPath, "cli", "logs"); public static string Root => Path.Combine(AbpRootPath, "cli"); public static string AccessToken => Path.Combine(AbpRootPath, "cli", "access-token.bin"); - + public static string Build => Path.Combine(AbpRootPath, "build"); + private static readonly string AbpRootPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".abp"); } } diff --git a/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/BuildCommand.cs b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/BuildCommand.cs new file mode 100644 index 0000000000..6d28b39fad --- /dev/null +++ b/framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Commands/BuildCommand.cs @@ -0,0 +1,132 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Volo.Abp.Cli.Args; +using Volo.Abp.Cli.Build; +using Volo.Abp.DependencyInjection; + +namespace Volo.Abp.Cli.Commands +{ + public class BuildCommand : IConsoleCommand, ITransientDependency + { + public IDotNetProjectDependencyFiller DotNetProjectDependencyFiller { get; set; } + + public IChangedProjectFinder ChangedProjectFinder { get; set; } + + public IDotNetProjectBuilder DotNetProjectBuilder { get; set; } + + public IRepositoryBuildStatusStore RepositoryBuildStatusStore { get; set; } + + public IDotNetProjectBuildConfigReader DotNetProjectBuildConfigReader { get; set; } + + public IBuildStatusGenerator BuildStatusGenerator { get; set; } + + public IBuildProjectListSorter BuildProjectListSorter { get; set; } + + public Task ExecuteAsync(CommandLineArgs commandLineArgs) + { + var sw = new Stopwatch(); + sw.Start(); + + var workingDirectory = commandLineArgs.Options.GetOrNull( + Options.WorkingDirectory.Short, + Options.WorkingDirectory.Long + ); + + var dotnetBuildArguments = commandLineArgs.Options.GetOrNull( + Options.DotnetBuildArguments.Short, + Options.DotnetBuildArguments.Long + ); + + var buildName = commandLineArgs.Options.GetOrNull( + Options.BuildName.Short, + Options.BuildName.Long + ); + + var forceBuild = commandLineArgs.Options.ContainsKey(Options.ForceBuild.Short) || + commandLineArgs.Options.ContainsKey(Options.ForceBuild.Long); + + var buildConfig = DotNetProjectBuildConfigReader.Read(workingDirectory ?? Directory.GetCurrentDirectory()); + buildConfig.BuildName = buildName; + buildConfig.ForceBuild = forceBuild; + + Console.WriteLine("Finding changed projects..."); + + var changedProjectFiles = ChangedProjectFinder.Find(buildConfig); + + var buildSucceededProjects = DotNetProjectBuilder.Build( + changedProjectFiles, + dotnetBuildArguments ?? "" + ); + + var buildStatus = BuildStatusGenerator.Generate( + buildConfig, + changedProjectFiles, + buildSucceededProjects + ); + + RepositoryBuildStatusStore.Set(buildName, buildConfig.GitRepository, buildStatus); + + sw.Stop(); + Console.WriteLine("Build operation is completed in " + sw.ElapsedMilliseconds + " (ms)"); + + return Task.CompletedTask; + } + + public string GetUsageInfo() + { + var sb = new StringBuilder(); + + sb.AppendLine(""); + sb.AppendLine("Usage:"); + sb.AppendLine(""); + sb.AppendLine(" abp build [options]"); + sb.AppendLine(""); + sb.AppendLine("Options:"); + sb.AppendLine(""); + sb.AppendLine("-wd|--working-directory (default: empty)"); + sb.AppendLine("-m |--max-parallel-builds (default: 1)"); + sb.AppendLine("-a |--dotnet-build-arguments (default: empty)"); + sb.AppendLine("-n |--build-name (default: empty)"); + sb.AppendLine("-f | --force (default: false)"); + sb.AppendLine(""); + sb.AppendLine("See the documentation for more info: https://docs.abp.io/en/abp/latest/CLI"); + + return sb.ToString(); + } + + public string GetShortDescription() + { + return "Builds a dotnet repository and dependent repositories or a solution."; + } + + public static class Options + { + public static class WorkingDirectory + { + public const string Short = "wd"; + public const string Long = "working-directory"; + } + + public static class DotnetBuildArguments + { + public const string Short = "a"; + public const string Long = "dotnet-build-arguments"; + } + + public static class BuildName + { + public const string Short = "n"; + public const string Long = "build-name"; + } + + public static class ForceBuild + { + public const string Short = "f"; + public const string Long = "force"; + } + } + } +} diff --git a/framework/src/Volo.Abp.Core/System/Collections/Generic/AbpListExtensions.cs b/framework/src/Volo.Abp.Core/System/Collections/Generic/AbpListExtensions.cs index ac71649793..23fed34f3c 100644 --- a/framework/src/Volo.Abp.Core/System/Collections/Generic/AbpListExtensions.cs +++ b/framework/src/Volo.Abp.Core/System/Collections/Generic/AbpListExtensions.cs @@ -188,18 +188,22 @@ namespace System.Collections.Generic /// The type of the members of values. /// A list of objects to sort /// Function to resolve the dependencies + /// Equality comparer for dependencies /// /// Returns a new list ordered by dependencies. /// If A depends on B, then B will come before than A in the resulting list. /// - public static List SortByDependencies(this IEnumerable source, Func> getDependencies) + public static List SortByDependencies( + this IEnumerable source, + Func> getDependencies, + IEqualityComparer comparer = null) { /* See: http://www.codeproject.com/Articles/869059/Topological-sorting-in-Csharp * http://en.wikipedia.org/wiki/Topological_sorting */ var sorted = new List(); - var visited = new Dictionary(); + var visited = new Dictionary(comparer); foreach (var item in source) { diff --git a/framework/test/Volo.Abp.Cli.Core.Tests/Volo/Abp/Cli/Build/BuildProjectListSorter_Tests.cs b/framework/test/Volo.Abp.Cli.Core.Tests/Volo/Abp/Cli/Build/BuildProjectListSorter_Tests.cs new file mode 100644 index 0000000000..1fde954ce2 --- /dev/null +++ b/framework/test/Volo.Abp.Cli.Core.Tests/Volo/Abp/Cli/Build/BuildProjectListSorter_Tests.cs @@ -0,0 +1,79 @@ +using System.Collections.Generic; +using Shouldly; +using Xunit; + +namespace Volo.Abp.Cli.Build +{ + public class BuildProjectListSorter_Tests : AbpCliTestBase + { + private IBuildProjectListSorter _buildProjectListSorter; + + public BuildProjectListSorter_Tests() + { + _buildProjectListSorter = GetRequiredService(); + } + + [Fact] + public void SortByDependencies_Test() + { + // A -> B, C + // B -> D + // D -> F + // F -> C + // C -> G + // Final build order must be: G, + + var repositoryName = "volo"; + var source = new List + { + new DotNetProjectInfo(repositoryName, "A", true) + { + Dependencies = new List() + { + new DotNetProjectInfo(repositoryName, "B", true), + new DotNetProjectInfo(repositoryName, "C", true) + } + }, + new DotNetProjectInfo(repositoryName, "B", true) + { + Dependencies = new List() + { + new DotNetProjectInfo(repositoryName, "D", true) + } + }, + new DotNetProjectInfo(repositoryName, "D", true) + { + Dependencies = new List() + { + new DotNetProjectInfo(repositoryName, "F", true) + } + }, + new DotNetProjectInfo(repositoryName, "F", true) + { + Dependencies = new List() + { + new DotNetProjectInfo(repositoryName, "C", true) + } + }, + new DotNetProjectInfo(repositoryName, "C", true) + { + Dependencies = new List() + { + new DotNetProjectInfo(repositoryName, "G", true) + } + }, + new DotNetProjectInfo(repositoryName, "G", true) + }; + + var sortedDependencies = + _buildProjectListSorter.SortByDependencies(source, new DotNetProjectInfoEqualityComparer()); + sortedDependencies.Count.ShouldBe(6); + sortedDependencies[0].CsProjPath.ShouldBe("G"); + sortedDependencies[1].CsProjPath.ShouldBe("C"); + sortedDependencies[2].CsProjPath.ShouldBe("F"); + sortedDependencies[3].CsProjPath.ShouldBe("D"); + sortedDependencies[4].CsProjPath.ShouldBe("B"); + sortedDependencies[5].CsProjPath.ShouldBe("A"); + } + } +} diff --git a/framework/test/Volo.Abp.Cli.Core.Tests/Volo/Abp/Cli/Build/BuildStatusGenerator_Tests.cs b/framework/test/Volo.Abp.Cli.Core.Tests/Volo/Abp/Cli/Build/BuildStatusGenerator_Tests.cs new file mode 100644 index 0000000000..24790a8a03 --- /dev/null +++ b/framework/test/Volo.Abp.Cli.Core.Tests/Volo/Abp/Cli/Build/BuildStatusGenerator_Tests.cs @@ -0,0 +1,123 @@ +using System.Collections.Generic; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using Shouldly; +using Xunit; + +namespace Volo.Abp.Cli.Build +{ + public class BuildStatusGenerator_Tests : AbpCliTestBase + { + private readonly IBuildStatusGenerator _buildStatusGenerator; + private IGitRepositoryHelper _gitRepositoryHelper; + + public BuildStatusGenerator_Tests() + { + _buildStatusGenerator = GetRequiredService(); + } + + protected override void AfterAddApplication(IServiceCollection services) + { + _gitRepositoryHelper = Substitute.For(); + services.AddTransient(provider => _gitRepositoryHelper); + } + + [Fact] + public void Should_Set_Repository_CommitId_When_All_Projects_Built() + { + var buildConfig = new DotNetProjectBuildConfig + { + GitRepository = new GitRepository("volo", "dev", "") + { + DependingRepositories = new List() + { + new GitRepository("abp", "dev", "") + } + } + }; + + var changedProjects = new List() + { + new DotNetProjectInfo("volo", "project1.csproj", true) + }; + + var builtProjects = new List() + { + "project1.csproj" + }; + + var lastCommitId = "1"; + _gitRepositoryHelper.GetLastCommitId(buildConfig.GitRepository).Returns(lastCommitId); + _gitRepositoryHelper.GetFriendlyName(buildConfig.GitRepository).Returns("volo"); + + var status = _buildStatusGenerator.Generate(buildConfig, changedProjects, builtProjects); + status.CommitId.ShouldBe(lastCommitId); + } + + + [Fact] + public void Should_Set_Repository_CommitId_When_All_Projects_Built_For_Child_Repository() + { + var buildConfig = new DotNetProjectBuildConfig + { + GitRepository = new GitRepository("volo", "dev", "") + { + DependingRepositories = new List() + { + new GitRepository("abp", "dev", "") + } + } + }; + + var changedProjects = new List() + { + new DotNetProjectInfo("abp", "project1.csproj", true) + }; + + var builtProjects = new List() + { + "project1.csproj" + }; + + var lastCommitId = "1"; + _gitRepositoryHelper.GetLastCommitId(buildConfig.GitRepository).Returns(lastCommitId); + _gitRepositoryHelper.GetFriendlyName(buildConfig.GitRepository).Returns("abp"); + + var status = _buildStatusGenerator.Generate(buildConfig, changedProjects, builtProjects); + status.CommitId.ShouldBe(lastCommitId); + } + + [Fact] + public void Should_Not_Set_Repository_CommitId_When_Building_Single_Solution() + { + var buildConfig = new DotNetProjectBuildConfig + { + GitRepository = new GitRepository("volo", "dev", "") + { + DependingRepositories = new List() + { + new GitRepository("abp", "dev", "") + } + }, + SlFilePath = "test.sln" + }; + + var changedProjects = new List() + { + new DotNetProjectInfo("volo", "project1.csproj", true) + }; + + var builtProjects = new List() + { + "project1.csproj" + }; + + var lastCommitId = "1"; + _gitRepositoryHelper.GetLastCommitId(buildConfig.GitRepository).Returns(lastCommitId); + _gitRepositoryHelper.GetFriendlyName(buildConfig.GitRepository).Returns("volo"); + + var status = _buildStatusGenerator.Generate(buildConfig, changedProjects, builtProjects); + status.CommitId.ShouldBeNull(); + } + } +} diff --git a/framework/test/Volo.Abp.Cli.Core.Tests/Volo/Abp/Cli/Build/GitRepositoryBuildStatus_Tests.cs b/framework/test/Volo.Abp.Cli.Core.Tests/Volo/Abp/Cli/Build/GitRepositoryBuildStatus_Tests.cs new file mode 100644 index 0000000000..387b30da7c --- /dev/null +++ b/framework/test/Volo.Abp.Cli.Core.Tests/Volo/Abp/Cli/Build/GitRepositoryBuildStatus_Tests.cs @@ -0,0 +1,318 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using Shouldly; +using Xunit; + +namespace Volo.Abp.Cli.Build +{ + public class GitRepositoryBuildStatus_Tests : AbpCliTestBase + { + private IGitRepositoryHelper _gitRepositoryHelper; + + protected override void AfterAddApplication(IServiceCollection services) + { + _gitRepositoryHelper = Substitute.For(); + services.AddTransient(provider => _gitRepositoryHelper); + } + + [Fact] + public void Add_New_Build_Status_Test() + { + var existingBuildStatus = new GitRepositoryBuildStatus("volo", "dev") + { + SucceedProjects = new List + { + new DotNetProjectBuildStatus + { + CsProjPath = "project1.csproj", + CommitId = "1" + } + } + }; + + var newBuildStatus = new GitRepositoryBuildStatus( + existingBuildStatus.RepositoryName, + existingBuildStatus.BranchName + ) + { + SucceedProjects = new List + { + new DotNetProjectBuildStatus + { + CsProjPath = "project2.csproj", + CommitId = "2" + } + } + }; + + existingBuildStatus.MergeWith(newBuildStatus); + + existingBuildStatus.SucceedProjects.Count.ShouldBe(2); + } + + [Fact] + public void Update_Existing_Build_Status_Test() + { + var existingBuildStatus = new GitRepositoryBuildStatus("volo", "dev") + { + SucceedProjects = new List + { + new DotNetProjectBuildStatus + { + CsProjPath = "project1.csproj", + CommitId = "1" + } + } + }; + + var newBuildStatus = new GitRepositoryBuildStatus( + existingBuildStatus.RepositoryName, + existingBuildStatus.BranchName + ) + { + SucceedProjects = new List + { + new DotNetProjectBuildStatus + { + CsProjPath = "project1.csproj", + CommitId = "2" + }, + new DotNetProjectBuildStatus + { + CsProjPath = "project2.csproj", + CommitId = "2" + } + } + }; + + existingBuildStatus.MergeWith(newBuildStatus); + existingBuildStatus.SucceedProjects.Count.ShouldBe(2); + existingBuildStatus.GetSelfOrChild("volo").SucceedProjects.First(p => p.CsProjPath == "project1.csproj") + .CommitId.ShouldBe("2"); + existingBuildStatus.GetSelfOrChild("volo").SucceedProjects.First(p => p.CsProjPath == "project2.csproj") + .CommitId.ShouldBe("2"); + } + + [Fact] + public void Add_New_Build_Status_For_Child_Repository_Test() + { + var existingBuildStatus = new GitRepositoryBuildStatus("volo", "dev") + { + DependingRepositories = new List() + { + new GitRepositoryBuildStatus("abp", "dev") + { + SucceedProjects = new List + { + new DotNetProjectBuildStatus + { + CsProjPath = "project1.csproj", + CommitId = "1" + } + } + } + } + }; + + var newBuildStatus = new GitRepositoryBuildStatus( + existingBuildStatus.RepositoryName, + existingBuildStatus.BranchName + ) + { + DependingRepositories = new List() + { + new GitRepositoryBuildStatus("abp", "dev") + { + SucceedProjects = new List + { + new DotNetProjectBuildStatus + { + CsProjPath = "project2.csproj", + CommitId = "2" + } + } + } + } + }; + + existingBuildStatus.MergeWith(newBuildStatus); + existingBuildStatus.GetChild("abp").SucceedProjects.Count.ShouldBe(2); + } + + [Fact] + public void Should_Update_Repository_CommitId_When_New_CommitId_Is_Not_Empty() + { + var existingBuildStatus = new GitRepositoryBuildStatus("volo", "dev"); + + var newBuildStatus = new GitRepositoryBuildStatus( + existingBuildStatus.RepositoryName, + existingBuildStatus.BranchName + ) + { + CommitId = "42" + }; + + existingBuildStatus.MergeWith(newBuildStatus); + + existingBuildStatus.CommitId.ShouldBe("42"); + } + + [Fact] + public void Should_Not_Update_Repository_CommitId_When_New_CommitId_Is_Empty() + { + var existingBuildStatus = new GitRepositoryBuildStatus("volo", "dev") + { + CommitId = "21" + }; + + var newBuildStatus = new GitRepositoryBuildStatus( + existingBuildStatus.RepositoryName, + existingBuildStatus.BranchName + ) + { + CommitId = "" + }; + + existingBuildStatus.MergeWith(newBuildStatus); + + existingBuildStatus.CommitId.ShouldBe("21"); + } + + [Fact] + public void GetChild_Test() + { + var existingBuildStatus = new GitRepositoryBuildStatus("repo-1", "dev") + { + DependingRepositories = new List() + { + new GitRepositoryBuildStatus("repo-2", "dev") + { + DependingRepositories = new List() + { + new GitRepositoryBuildStatus("repo-3", "dev") + } + }, + new GitRepositoryBuildStatus("repo-4", "dev") + } + }; + + existingBuildStatus.GetChild("repo-3").RepositoryName.ShouldBe("repo-3"); + existingBuildStatus.GetChild("repo-4").RepositoryName.ShouldBe("repo-4"); + } + + [Fact] + public void GetUniqueName_Test() + { + var existingBuildStatus = new GitRepositoryBuildStatus("repo-1", "dev") + { + DependingRepositories = new List() + { + new GitRepositoryBuildStatus("repo-2", "dev") + { + DependingRepositories = new List() + { + new GitRepositoryBuildStatus("repo-3", "dev") + } + }, + new GitRepositoryBuildStatus("repo-4", "dev") + } + }; + + existingBuildStatus.GetUniqueName("").ShouldBe("B25C935F97D7B3375530A96B392B7644"); + existingBuildStatus.GetUniqueName("production").ShouldBe("production_B25C935F97D7B3375530A96B392B7644"); + } + + [Fact] + public void GetSelfOrChild_Test() + { + var existingBuildStatus = new GitRepositoryBuildStatus("repo-1", "dev") + { + DependingRepositories = new List() + { + new GitRepositoryBuildStatus("repo-2", "dev") + { + DependingRepositories = new List() + { + new GitRepositoryBuildStatus("repo-3", "dev") + } + }, + new GitRepositoryBuildStatus("repo-4", "dev") + } + }; + + existingBuildStatus.GetSelfOrChild("repo-1").RepositoryName.ShouldBe("repo-1"); + existingBuildStatus.GetSelfOrChild("repo-2").RepositoryName.ShouldBe("repo-2"); + existingBuildStatus.GetSelfOrChild("repo-3").RepositoryName.ShouldBe("repo-3"); + existingBuildStatus.GetSelfOrChild("repo-4").RepositoryName.ShouldBe("repo-4"); + } + + [Fact] + public void AddOrUpdateProjectStatus_Test() + { + var existingBuildStatus = new GitRepositoryBuildStatus("repo-1", "dev") + { + DependingRepositories = new List() + { + new GitRepositoryBuildStatus("repo-2", "dev") + { + SucceedProjects = new List + { + new DotNetProjectBuildStatus + { + CsProjPath = "A.csproj", + CommitId = "42" + } + }, + DependingRepositories = new List + { + new GitRepositoryBuildStatus("repo-3", "dev") + { + SucceedProjects = new List + { + new DotNetProjectBuildStatus + { + CsProjPath = "B.csproj", + CommitId = "42" + } + } + } + } + }, + new GitRepositoryBuildStatus("repo-4", "dev") + { + SucceedProjects = new List() + { + new DotNetProjectBuildStatus + { + CsProjPath = "C.csproj", + CommitId = "42" + } + } + } + } + }; + + var repo2 = existingBuildStatus.GetChild("repo-2"); + repo2.AddOrUpdateProjectStatus(new DotNetProjectBuildStatus + { + CommitId = "21", + CsProjPath = "A.csproj" + }); + + var repo3 = existingBuildStatus.GetChild("repo-3"); + repo3.AddOrUpdateProjectStatus(new DotNetProjectBuildStatus + { + CommitId = "21", + CsProjPath = "X.csproj" + }); + + repo2.SucceedProjects.Count.ShouldBe(1); + repo2.SucceedProjects.ShouldContain(e=> e.CsProjPath == "A.csproj" && e.CommitId == "21"); + + repo3.SucceedProjects.Count.ShouldBe(2); + repo3.SucceedProjects.ShouldContain(e=> e.CsProjPath == "X.csproj" && e.CommitId == "21"); + } + } +} diff --git a/framework/test/Volo.Abp.Cli.Core.Tests/Volo/Abp/Cli/Build/GitRepository_Tests.cs b/framework/test/Volo.Abp.Cli.Core.Tests/Volo/Abp/Cli/Build/GitRepository_Tests.cs new file mode 100644 index 0000000000..17cc8b9dc4 --- /dev/null +++ b/framework/test/Volo.Abp.Cli.Core.Tests/Volo/Abp/Cli/Build/GitRepository_Tests.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using Shouldly; +using Xunit; + +namespace Volo.Abp.Cli.Build +{ + public class GitRepository_Tests : AbpCliTestBase + { + [Fact] + public void GetUniqueName_Test() + { + var gitRepository = new GitRepository("repo-1", "dev", "") + { + DependingRepositories = new List + { + new GitRepository("repo-2", "dev", ""), + new GitRepository("repo-3", "dev", "") + { + DependingRepositories = new List() + { + new GitRepository("repo-4", "dev", "") + } + } + } + }; + + gitRepository.GetUniqueName("").ShouldBe("B25C935F97D7B3375530A96B392B7644"); + gitRepository.GetUniqueName("production").ShouldBe("production_B25C935F97D7B3375530A96B392B7644"); + } + + [Fact] + public void FindRepositoryOf_Test() + { + var gitRepository = new GitRepository("repo-1", "dev", "/repo-1/dev/") + { + DependingRepositories = new List + { + new GitRepository("repo-2", "dev", "/repo-2/dev/"), + new GitRepository("repo-3", "dev", "/repo-3/dev/") + { + DependingRepositories = new List() + { + new GitRepository("repo-4", "dev", "/repo-4/dev/") + } + } + } + }; + + gitRepository.FindRepositoryOf("/repo-4/dev/A.csproj").ShouldBe("repo-4"); + } + } +}