Skip to content

Commit b268291

Browse files
support solution-wide building when it's the better option (#314)
* Added `BuildAnalysisResult` and our `SolutionWideDetector` * handle `.props` / `.target` importing * working on integrating `FullSolutionBuildResult` into commands * optimized `RunDotNetCommandTask` * fixed unit test * replaced `Console.Writeline` with `ILogger` calls * remove unnecessary project reference
1 parent 6d0fc28 commit b268291

12 files changed

+675
-103
lines changed

src/Directory.Packages.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
<PackageVersion Include="CommandLineParser" Version="2.9.1" />
99
<PackageVersion Include="Microsoft.Build.Locator" Version="1.7.8" />
1010
<PackageVersion Include="Microsoft.CodeAnalysis.Workspaces.MSBuild" Version="$(RoslynVersion)" />
11-
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="9.0.2" />
1211
<PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.2" />
12+
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="9.0.2" />
1313
<PackageVersion Include="NuGet.ProjectModel" Version="$(NugetVersion)" />
1414
</ItemGroup>
1515

src/Incrementalist.Cmd/Commands/EmitDependencyGraphTask.cs

Lines changed: 56 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,56 +4,96 @@
44
// </copyright>
55
// -----------------------------------------------------------------------
66

7+
using System;
78
using System.Collections.Generic;
9+
using System.Linq;
810
using System.Threading;
911
using System.Threading.Tasks;
1012
using Incrementalist.Git;
1113
using Incrementalist.ProjectSystem;
1214
using Incrementalist.ProjectSystem.Cmds;
15+
using Microsoft.CodeAnalysis;
1316
using Microsoft.CodeAnalysis.MSBuild;
1417
using Microsoft.Extensions.Logging;
1518

1619
namespace Incrementalist.Cmd.Commands
1720
{
1821
/// <summary>
19-
/// Used to emit an entire dependency graph based on which files in
20-
/// a solution were affected.
22+
/// Analyzes changes and determines whether a full solution build or incremental build is required.
2123
/// </summary>
2224
public sealed class EmitDependencyGraphTask
2325
{
2426
private readonly CancellationTokenSource _cts;
2527

26-
private readonly MSBuildWorkspace _workspace;
27-
2828
public EmitDependencyGraphTask(BuildSettings settings, MSBuildWorkspace workspace, ILogger logger)
2929
{
3030
Settings = settings;
31-
_workspace = workspace;
31+
Workspace = workspace;
3232
Logger = logger;
3333
_cts = new CancellationTokenSource();
3434
}
3535

3636
public BuildSettings Settings { get; }
3737

38+
public MSBuildWorkspace Workspace { get; }
39+
3840
public ILogger Logger { get; }
3941

40-
public async Task<Dictionary<string, ICollection<string>>> Run()
42+
public async Task<BuildAnalysisResult> Run()
4143
{
4244
// start the cancellation timer.
4345
_cts.CancelAfter(Settings.TimeoutDuration);
4446

45-
var loadSln = new LoadSolutionCmd(Logger, _workspace, _cts.Token);
46-
var slnFile = await loadSln.Process(Task.FromResult(Settings.SolutionFile));
47-
47+
var solution = await Workspace.OpenSolutionAsync(Settings.SolutionFile, null, _cts.Token);
4848

4949
var getFilesCmd = new GatherAllFilesInSolutionCmd(Logger, _cts.Token, Settings.WorkingDirectory);
50-
var filterFilesCmd =
51-
new FilterAffectedProjectFilesCmd(Logger, _cts.Token, Settings.WorkingDirectory, Settings.TargetBranch);
52-
var createDependencyGraph = new ComputeDependencyGraphCmd(Logger, _cts.Token, slnFile);
53-
var affectedFiles =
54-
await createDependencyGraph.Process(
55-
filterFilesCmd.Process(getFilesCmd.Process(Task.FromResult(slnFile))));
56-
return affectedFiles;
50+
var filterFilesCmd = new FilterAffectedProjectFilesCmd(Logger, _cts.Token, Settings.WorkingDirectory, Settings.TargetBranch);
51+
52+
// Get all files and filter affected ones
53+
var allFiles = await getFilesCmd.Process(Task.FromResult(solution));
54+
var affectedFiles = await filterFilesCmd.Process(Task.FromResult(allFiles));
55+
56+
// Early check: if no files are affected, return an incremental build with empty list
57+
if (!affectedFiles.Any())
58+
{
59+
Logger.LogInformation("No files were affected by the changes");
60+
return new IncrementalBuildResult(Array.Empty<string>());
61+
}
62+
63+
// Check if any of the affected files require a solution-wide build
64+
var projectFiles = allFiles.Where(x => x.Value.FileType == FileType.Project)
65+
.Select(pair => new SlnFileWithPath(pair.Key, pair.Value))
66+
.ToList();
67+
var projectImports = ProjectImportsFinder.FindProjectImports(projectFiles);
68+
var detector = new SolutionWideChangeDetector(projectImports);
69+
70+
if (detector.RequiresFullSolutionBuild(affectedFiles.Keys))
71+
{
72+
Logger.LogInformation("Solution-wide changes detected. Full solution build required");
73+
return new FullSolutionBuildResult(solution.FilePath);
74+
}
75+
76+
// Get the list of affected projects
77+
var affectedProjects = affectedFiles.Where(x => x.Value.FileType == FileType.Project)
78+
.Select(x => x.Key)
79+
.ToList();
80+
81+
// If all projects are affected, return a full solution build
82+
if (affectedProjects.Count == solution.Projects.Count())
83+
{
84+
Logger.LogInformation("All projects are affected. Full solution build required");
85+
return new FullSolutionBuildResult(solution.FilePath);
86+
}
87+
88+
// For incremental builds, compute the dependency graph
89+
var createDependencyGraph = new ComputeDependencyGraphCmd(Logger, _cts.Token, solution);
90+
var dependencyGraph = await createDependencyGraph.Process(Task.FromResult(affectedFiles));
91+
92+
// Convert the dependency graph to a list of affected projects
93+
var projectsToRebuild = dependencyGraph.SelectMany(x => x.Value).Distinct().ToList();
94+
95+
Logger.LogInformation($"Incremental build possible. {projectsToRebuild.Count} projects need to be rebuilt");
96+
return new IncrementalBuildResult(projectsToRebuild);
5797
}
5898
}
5999
}

src/Incrementalist.Cmd/Commands/RunDotNetCommandTask.cs

Lines changed: 79 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,26 @@ public RunDotNetCommandTask(BuildSettings settings, ILogger logger, string[] dot
3535
_failOnNoProjects = failOnNoProjects;
3636
}
3737

38-
public async Task<int> Run(IEnumerable<string> affectedProjects)
38+
public async Task<int> Run(BuildAnalysisResult buildResult)
39+
{
40+
switch (buildResult)
41+
{
42+
case FullSolutionBuildResult full:
43+
return await RunSolutionBuild(full.SolutionPath);
44+
case IncrementalBuildResult incremental:
45+
return await RunIncrementalBuild(incremental.AffectedProjects);
46+
default:
47+
throw new InvalidOperationException($"Unknown build result type: {buildResult.GetType()}");
48+
}
49+
}
50+
51+
private async Task<int> RunSolutionBuild(string solutionPath)
52+
{
53+
_logger.LogInformation("Running '{0}' against solution {1}", string.Join(" ", _dotnetArgs), solutionPath);
54+
return await RunCommand(solutionPath);
55+
}
56+
57+
private async Task<int> RunIncrementalBuild(IEnumerable<string> affectedProjects)
3958
{
4059
var projects = affectedProjects.ToList();
4160
if (!projects.Any())
@@ -48,69 +67,11 @@ public async Task<int> Run(IEnumerable<string> affectedProjects)
4867

4968
var failedProjects = new List<string>();
5069

51-
async Task<bool> RunCommand(string project)
52-
{
53-
// For dotnet CLI commands like 'build', 'test', etc., the project path comes last
54-
var args = string.Join(" ", _dotnetArgs);
55-
if (!args.Contains("--project") && !args.Contains("-p"))
56-
args = $"{args} \"{project}\"";
57-
58-
var process = new Process
59-
{
60-
StartInfo = new ProcessStartInfo
61-
{
62-
FileName = "dotnet",
63-
Arguments = args,
64-
UseShellExecute = false,
65-
RedirectStandardOutput = true,
66-
RedirectStandardError = true,
67-
WorkingDirectory = _settings.WorkingDirectory
68-
}
69-
};
70-
71-
process.OutputDataReceived += (sender, eventArgs) =>
72-
{
73-
if (!string.IsNullOrEmpty(eventArgs.Data))
74-
_logger.LogInformation("[{0}] {1}", project, eventArgs.Data);
75-
};
76-
77-
process.ErrorDataReceived += (sender, eventArgs) =>
78-
{
79-
if (!string.IsNullOrEmpty(eventArgs.Data))
80-
_logger.LogError("[{0}] {1}", project, eventArgs.Data);
81-
};
82-
83-
try
84-
{
85-
process.Start();
86-
process.BeginOutputReadLine();
87-
process.BeginErrorReadLine();
88-
await process.WaitForExitAsync();
89-
90-
if (process.ExitCode != 0)
91-
{
92-
_logger.LogError("Command failed for project {0} with exit code {1}", project, process.ExitCode);
93-
return false;
94-
}
95-
96-
return true;
97-
}
98-
catch (Exception ex)
99-
{
100-
_logger.LogError(ex, "Failed to execute command for project {0}", project);
101-
return false;
102-
}
103-
finally
104-
{
105-
process.Dispose();
106-
}
107-
}
108-
10970
if (_runInParallel)
11071
{
11172
var tasks = projects.Select(async project =>
11273
{
113-
if (!await RunCommand(project))
74+
if (await RunCommand(project) != 0)
11475
{
11576
failedProjects.Add(project);
11677
if (!_continueOnError)
@@ -124,7 +85,7 @@ async Task<bool> RunCommand(string project)
12485
{
12586
foreach (var project in projects)
12687
{
127-
if (!await RunCommand(project))
88+
if (await RunCommand(project) != 0)
12889
{
12990
failedProjects.Add(project);
13091
if (!_continueOnError)
@@ -141,5 +102,62 @@ async Task<bool> RunCommand(string project)
141102

142103
return 0;
143104
}
105+
106+
private async Task<int> RunCommand(string target)
107+
{
108+
// For dotnet CLI commands like 'build', 'test', etc., the project/solution path comes last
109+
var args = string.Join(" ", _dotnetArgs);
110+
if (!args.Contains("--project") && !args.Contains("-p"))
111+
args = $"{args} \"{target}\"";
112+
113+
var process = new Process
114+
{
115+
StartInfo = new ProcessStartInfo
116+
{
117+
FileName = "dotnet",
118+
Arguments = args,
119+
UseShellExecute = false,
120+
RedirectStandardOutput = true,
121+
RedirectStandardError = true,
122+
WorkingDirectory = _settings.WorkingDirectory
123+
}
124+
};
125+
126+
process.OutputDataReceived += (sender, eventArgs) =>
127+
{
128+
if (!string.IsNullOrEmpty(eventArgs.Data))
129+
_logger.LogInformation("[{0}] {1}", target, eventArgs.Data);
130+
};
131+
132+
process.ErrorDataReceived += (sender, eventArgs) =>
133+
{
134+
if (!string.IsNullOrEmpty(eventArgs.Data))
135+
_logger.LogError("[{0}] {1}", target, eventArgs.Data);
136+
};
137+
138+
try
139+
{
140+
process.Start();
141+
process.BeginOutputReadLine();
142+
process.BeginErrorReadLine();
143+
await process.WaitForExitAsync();
144+
145+
if (process.ExitCode != 0)
146+
{
147+
_logger.LogError("Command failed for {0} with exit code {1}", target, process.ExitCode);
148+
}
149+
150+
return process.ExitCode;
151+
}
152+
catch (Exception ex)
153+
{
154+
_logger.LogError(ex, "Failed to execute command for {0}", target);
155+
return 1;
156+
}
157+
finally
158+
{
159+
process.Dispose();
160+
}
161+
}
144162
}
145163
}

0 commit comments

Comments
 (0)