diff --git a/.vsts-ci.yml b/.vsts-ci.yml
index 7a69824b298c..a45d0c784f08 100644
--- a/.vsts-ci.yml
+++ b/.vsts-ci.yml
@@ -366,16 +366,6 @@ extends:
runtimeIdentifier: osx-arm64
osProperties: /p:CrossBuild=true
- ############### DOTNET-FORMAT ###############
- - ${{ if or(eq(parameters.runTestBuild, true), eq(variables['Build.Reason'], 'PullRequest')) }}:
- - template: /eng/dotnet-format/dotnet-format-integration.yml@self
- parameters:
- oneESCompat:
- templateFolderName: templates-official
- publishTaskPrefix: 1ES.
- populateInternalRuntimeVariables: true
- runtimeSourceProperties: /p:DotNetRuntimeSourceFeed=https://ci.dot.net/internal /p:DotNetRuntimeSourceFeedKey=$(dotnetbuilds-internal-container-read-token-base64)
-
############### PUBLISH STAGE ###############
- ${{ if ne(variables['Build.Reason'], 'PullRequest') }}:
- stage: publish
diff --git a/.vsts-pr.yml b/.vsts-pr.yml
index db1ae21f8fed..ceb6222ad86f 100644
--- a/.vsts-pr.yml
+++ b/.vsts-pr.yml
@@ -157,5 +157,3 @@ stages:
macOSJobParameterSets:
- runtimeIdentifier: osx-x64
- ############### DOTNET-FORMAT ###############
- - template: /eng/dotnet-format/dotnet-format-integration.yml
diff --git a/CODEOWNERS b/CODEOWNERS
index b9ffd1b4c554..d9873922d382 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -54,8 +54,8 @@
/src/WasmSdk @lewing @akoeplinger @pavelsavara @maraf
# Area-Format
-/src/Cli/dotnet/Commands/Format @phil-allen-msft
-/test/dotnet-format.UnitTests @phil-allen-msft
+/src/Cli/dotnet/Commands/Format @mwiemer-microsoft
+/test/dotnet-format.*Tests @mwiemer-microsoft
# Area-NuGet
/src/Cli/dotnet/Commands/NuGet @dotnet/nuget-team
@@ -119,7 +119,7 @@
# Area-Format
/src/BuiltInTools/dotnet-format @dotnet/roslyn-ide
-/test/dotnet-format.UnitTests @dotnet/roslyn-ide
+/test/dotnet-format.*Tests @dotnet/roslyn-ide
# Area-Microsoft.CodeAnalysis.NetAnalyzers
/src/Microsoft.CodeAnalysis.NetAnalyzers @dotnet/dotnet-analyzers
diff --git a/eng/dotnet-format/dotnet-format-integration.yml b/eng/dotnet-format/dotnet-format-integration.yml
deleted file mode 100644
index 8df9e7fe7b05..000000000000
--- a/eng/dotnet-format/dotnet-format-integration.yml
+++ /dev/null
@@ -1,123 +0,0 @@
-parameters:
-- name: oneESCompat
- type: object
- default:
- templateFolderName: templates
- publishTaskPrefix: ''
-
-- name: TestArguments
- type: object
- default:
- # Commit SHAs align with the .NET 10.0 GA release (v10.0.100).
- # See https://github.com/dotnet/dotnet/blob/v10.0.100/src/source-manifest.json
- - Name: Roslyn
- _repo: "https://github.com/dotnet/roslyn"
- _repoName: "dotnet/roslyn"
- _targetSolution: "Compilers.slnf"
- _branchName: "main"
- _sha: "739dc0e352a331e8a41cd66c09d2edf359255365"
- _useParentSdk: 0
- - Name: sdk
- _repo: "https://github.com/dotnet/sdk"
- _repoName: "dotnet/sdk"
- _targetSolution: "sdk.slnx"
- _branchName: "main"
- _sha: "e6bc966cc3d1348265b0831c6daca23267169d8f"
- _useParentSdk: 0
- - Name: project_system
- _repo: "https://github.com/dotnet/project-system"
- _repoName: "dotnet/project-system"
- _targetSolution: "ProjectSystem.sln"
- _branchName: "main"
- _sha: "e660d54d6b3198751bd0502fe270e1657f32a913"
- _useParentSdk: 1
- - Name: msbuild
- _repo: "https://github.com/dotnet/msbuild"
- _repoName: "dotnet/msbuild"
- _targetSolution: "MSBuild.sln"
- _branchName: "main"
- _sha: "995a3dce41788caebf2b8ca6602a7431f08bfd06"
- _useParentSdk: 0
- - Name: aspnetcore
- _repo: "https://github.com/dotnet/aspnetcore"
- _repoName: "dotnet/aspnetcore"
- _targetSolution: "AspNetCore.slnx"
- _branchName: "main"
- _sha: "7387de91234d3ef751fa50b3d1bfede4130213ff"
- _useParentSdk: 0
- - Name: efcore
- _repo: "https://github.com/dotnet/efcore"
- _repoName: "dotnet/efcore"
- _targetSolution: "EFCore.sln"
- _branchName: "main"
- _sha: "12b8d44bf691d2e6933a6d1003647cce4f13c3d3"
- _useParentSdk: 0
-
-- name: timeoutInMinutes
- type: number
- default: 90
-- name: runtimeSourceProperties
- type: string
- default: ''
-- name: populateInternalRuntimeVariables
- type: boolean
- default: false
-
-jobs:
-- job: Formatting_Check
- displayName: Run Formatting Check
- pool:
- ${{ if eq(variables['System.TeamProject'], 'public') }}:
- name: $(DncEngPublicBuildPool)
- demands: ImageOverride -equals 1es-windows-2022-open
- ${{ if eq(variables['System.TeamProject'], 'internal') }}:
- name: $(DncEngInternalBuildPool)
- image: 1es-windows-2022
- os: windows
- timeoutInMinutes: ${{ parameters.timeoutInMinutes }}
- steps:
- - ${{ if eq(parameters.populateInternalRuntimeVariables, true) }}:
- - template: /eng/common/${{ parameters.oneESCompat.templateFolderName }}/steps/enable-internal-sources.yml
- parameters:
- legacyCredential: $(dn-bot-dnceng-artifact-feeds-rw)
- - template: /eng/common/${{ parameters.oneESCompat.templateFolderName }}/steps/enable-internal-runtimes.yml
- - script: .\restore.cmd ${{ parameters.runtimeSourceProperties }}
- displayName: 🟣 Restore dependencies
- - script: |
- .\artifacts\sdk-build-env.bat
- dotnet run --project .\src\Dotnet.Format\dotnet-format\dotnet-format.csproj -c Release -- @eng\dotnet-format\validate.rsp
- displayName: 🟣 Run dotnet-format
- - task: ${{ parameters.oneESCompat.publishTaskPrefix }}PublishBuildArtifacts@1
- displayName: 🟣 Publish Logs
- inputs:
- PathtoPublish: $(Build.ArtifactStagingDirectory)
- ArtifactName: dotnet-format formatting Check
- continueOnError: true
- condition: not(succeeded())
-
-- ${{ each testArgs in parameters.TestArguments }}:
- - job: dotnet_format_integration_tests_${{ testArgs.Name }}
- displayName: 'Dotnet-Format Integration Tests: ${{ testArgs.Name }}'
- pool:
- ${{ if eq(variables['System.TeamProject'], 'public') }}:
- name: $(DncEngPublicBuildPool)
- demands: ImageOverride -equals 1es-windows-2022-open
- ${{ if eq(variables['System.TeamProject'], 'internal') }}:
- name: $(DncEngInternalBuildPool)
- image: 1es-windows-2022
- os: windows
- timeoutInMinutes: ${{ parameters.timeoutInMinutes }}
- steps:
- - ${{ if eq(parameters.populateInternalRuntimeVariables, true) }}:
- - template: /eng/common/${{ parameters.oneESCompat.templateFolderName }}/steps/enable-internal-sources.yml
- parameters:
- legacyCredential: $(dn-bot-dnceng-artifact-feeds-rw)
- - template: /eng/common/${{ parameters.oneESCompat.templateFolderName }}/steps/enable-internal-runtimes.yml
- - script: eng\dotnet-format\integration-test.cmd -repo '${{ testArgs._repo }}' -branchName '${{ testArgs._branchName }}' -sha '${{ testArgs._sha }}' -targetSolution '${{ testArgs._targetSolution }}' -useParentSdk ${{ testArgs._useParentSdk }} -testPath '$(Agent.TempDirectory)\temp' -stage 'prepare' -runtimeSourceProperties '${{ parameters.runtimeSourceProperties }}'
- displayName: 🟣 Prepare ${{ testArgs._repoName }} for formatting
-
- - script: eng\dotnet-format\integration-test.cmd -repo '${{ testArgs._repo }}' -branchName '${{ testArgs._branchName }}' -sha '${{ testArgs._sha }}' -targetSolution '${{ testArgs._targetSolution }}' -useParentSdk ${{ testArgs._useParentSdk }} -testPath '$(Agent.TempDirectory)\temp' -stage 'format-workspace'
- displayName: 🟣 Run dotnet-format on ${{ testArgs._repoName }} ${{ testArgs._targetSolution }}
-
- - script: eng\dotnet-format\integration-test.cmd -repo '${{ testArgs._repo }}' -branchName '${{ testArgs._branchName }}' -sha '${{ testArgs._sha }}' -targetSolution '${{ testArgs._targetSolution }}' -useParentSdk ${{ testArgs._useParentSdk }} -testPath '$(Agent.TempDirectory)\temp' -stage 'format-folder'
- displayName: 🟣 Run dotnet-format on ${{ testArgs._repoName }} repo folder
diff --git a/eng/dotnet-format/format-verifier.ps1 b/eng/dotnet-format/format-verifier.ps1
deleted file mode 100644
index c165a5dd7f22..000000000000
--- a/eng/dotnet-format/format-verifier.ps1
+++ /dev/null
@@ -1,138 +0,0 @@
-[CmdletBinding(PositionalBinding = $false)]
-Param(
- [string]$repo,
- [string]$sha,
- [string]$branchName,
- [string]$targetSolution,
- [string]$runtimeSourceProperties,
- [bool]$useParentSdk,
- [string]$testPath,
- [string]$stage # Valid values are "prepare", "format-workspace", "format-folder"
-)
-
-if ($stage -eq "prepare") {
- Write-Output "$(Get-Date) - Building dotnet-format."
- .\build.cmd -c Release $runtimeSourceProperties
-}
-
-$currentLocation = Get-Location
-$dotnetPath = Join-Path $currentLocation ".dotnet"
-$parentDotNetPath = Join-Path $dotnetPath "dotnet.exe"
-
-if (!(Test-Path $testPath)) {
- New-Item -ItemType Directory -Force -Path $testPath | Out-Null
-}
-
-try {
- $repoName = $repo.Substring(19)
- $folderName = $repoName.Split("/")[1]
- $repoPath = Join-Path $testPath $folderName
- $dllPath = Get-ChildItem -Path "$currentLocation/artifacts/bin/dotnet-format/Release/" -Include dotnet-format.dll -Recurse
-
- if (!(Test-Path $repoPath)) {
- New-Item -ItemType Directory -Force -Path $repoPath | Out-Null
- }
-
- Set-Location $repoPath
-
- if ($stage -eq "prepare") {
- Write-Output "$(Get-Date) - Cloning $repoName."
- git.exe init
- git.exe remote add origin $repo
- git.exe fetch --progress --no-tags --depth=1 origin $sha
- git.exe checkout $sha
- }
-
- # We invoke build.ps1 ourselves because running `restore.cmd` invokes the build.ps1
- # in a child process which means added .NET Core SDKs aren't visible to this process.
- if (Test-Path '.\eng\Build.ps1') {
- Write-Output "$(Get-Date) - Running Build.ps1 -restore"
- .\eng\Build.ps1 -restore
- }
- elseif (Test-Path '.\eng\common\Build.ps1') {
- Write-Output "$(Get-Date) - Running Build.ps1 -restore"
- .\eng\common\Build.ps1 -restore
- }
-
- if ($stage -eq "prepare" -or $stage -eq "format-workspace") {
- Write-Output "$(Get-Date) - Finding solutions."
- $solutions = Get-ChildItem -Include *.sln,*.slnf,*.slnx -Recurse -Depth 2 | Select-Object -ExpandProperty FullName
-
- $solutionFound = $false
- foreach ($solution in $solutions) {
- $solutionPath = Split-Path $solution
- $solutionFile = Split-Path $solution -leaf
-
- if ($solutionFile -ne $targetSolution) {
- continue
- }
-
- $solutionFound = $true
- Set-Location $solutionPath
-
- if ($stage -eq "prepare") {
- Write-Output "$(Get-Date) - $solutionFile - Restoring"
-
- # Restore the solution either with the repo local sdk or optionally with the dotnet/format SDK.
- if ($useParentSdk) {
- & $parentDotNetPath restore $solution --configfile nuget.config
- } else {
- dotnet.exe restore $solution
- }
- }
-
- if ($stage -eq "format-workspace") {
- Write-Output "$(Get-Date) - $solutionFile - Formatting Workspace"
- $output = & $parentDotNetPath "$dllPath" $solution --no-restore -v diag --verify-no-changes | Out-String
- Write-Output $output.TrimEnd()
-
- # Ignore CheckFailedExitCode since we don't expect these repos to be properly formatted.
- if ($LastExitCode -ne 0 -and $LastExitCode -ne 2) {
- Write-Output "$(Get-Date) - Formatting failed with error code $LastExitCode."
- exit -1
- }
-
- if (($output -notmatch "(?m)Formatted \d+ of (\d+) files") -or ($Matches[1] -eq "0")) {
- Write-Output "$(Get-Date) - No files found for solution."
- # The dotnet/sdk has a toolset solution with no files.
- # exit -1
- }
- }
-
- Write-Output "$(Get-Date) - $solutionFile - Complete"
- }
-
- if (-not $solutionFound) {
- $solutionNames = $solutions | ForEach-Object { Split-Path $_ -Leaf }
- Write-Output "$(Get-Date) - Target solution '$targetSolution' was not found. Available solutions ($($solutionNames.Count)): $($solutionNames -join ', ')"
- exit -1
- }
- }
-
- if ($stage -eq "format-folder") {
- Write-Output "$(Get-Date) - $folderName - Formatting Folder"
- $output = & $parentDotNetPath "$dllPath" whitespace $repoPath --folder -v diag --verify-no-changes | Out-String
- Write-Output $output.TrimEnd()
-
- # Ignore CheckFailedExitCode since we don't expect these repos to be properly formatted.
- if ($LastExitCode -ne 0 -and $LastExitCode -ne 2) {
- Write-Output "$(Get-Date) - Formatting failed with error code $LastExitCode."
- exit -1
- }
-
- if (($output -notmatch "(?m)Formatted \d+ of (\d+) files") -or ($Matches[1] -eq "0")) {
- Write-Output "$(Get-Date) - No files found for solution."
- exit -1
- }
-
- Write-Output "$(Get-Date) - $folderName - Complete"
- }
-
- exit 0
-}
-catch {
- exit -1
-}
-finally {
- Set-Location $currentLocation
-}
diff --git a/eng/dotnet-format/integration-test.cmd b/eng/dotnet-format/integration-test.cmd
deleted file mode 100644
index bb23395fc926..000000000000
--- a/eng/dotnet-format/integration-test.cmd
+++ /dev/null
@@ -1,3 +0,0 @@
-@echo off
-powershell -ExecutionPolicy ByPass -NoProfile -command "& """%~dp0format-verifier.ps1""" %*"
-exit /b %ErrorLevel%
\ No newline at end of file
diff --git a/eng/dotnet-format/validate.rsp b/eng/dotnet-format/validate.rsp
deleted file mode 100644
index d38768e941ab..000000000000
--- a/eng/dotnet-format/validate.rsp
+++ /dev/null
@@ -1,8 +0,0 @@
-./src/Dotnet.Format/dotnet-format.slnf
---exclude
-./tests/projects/
---verify-no-changes
---report
-./artifacts/log/
--v
-diag
diff --git a/sdk.slnx b/sdk.slnx
index 8da3d7601ec6..2fabaae4c973 100644
--- a/sdk.slnx
+++ b/sdk.slnx
@@ -349,6 +349,7 @@
+
diff --git a/src/Dotnet.Format/dotnet-format.slnf b/src/Dotnet.Format/dotnet-format.slnf
index 6f3ae502a927..78140c7a00ce 100644
--- a/src/Dotnet.Format/dotnet-format.slnf
+++ b/src/Dotnet.Format/dotnet-format.slnf
@@ -3,7 +3,8 @@
"path": "..\\..\\sdk.slnx",
"projects": [
"src\\Dotnet.Format\\dotnet-format\\dotnet-format.csproj",
- "test\\dotnet-format.UnitTests\\dotnet-format.UnitTests.csproj"
+ "test\\dotnet-format.UnitTests\\dotnet-format.UnitTests.csproj",
+ "test\\dotnet-format.IntegrationTests\\dotnet-format.IntegrationTests.csproj"
]
}
}
diff --git a/test/ConditionalTests.props b/test/ConditionalTests.props
index e89ed62fa14a..14f96ae750be 100644
--- a/test/ConditionalTests.props
+++ b/test/ConditionalTests.props
@@ -45,18 +45,16 @@
CI
-
+
project
- test/TemplateEngine/**/*.csproj
+ test/dotnet-format.*/**/*.csproj
- documentation/TemplateEngine/Samples/**;
- src/TemplateEngine/**;
- template_feed/Microsoft.TemplateEngine.Authoring.Templates/**;
- test/TemplateEngine/**;
- test/TestAssets/TestPackages/TemplateEngine/**
+ src/Dotnet.Format/**;
+ test/dotnet-format.*/**
CI
+
project
src/Microsoft.CodeAnalysis.NetAnalyzers/tests/**/*.csproj
@@ -65,6 +63,19 @@
CI
+
+
+ project
+ test/TemplateEngine/**/*.csproj
+
+ documentation/TemplateEngine/Samples/**;
+ src/TemplateEngine/**;
+ template_feed/Microsoft.TemplateEngine.Authoring.Templates/**;
+ test/TemplateEngine/**;
+ test/TestAssets/TestPackages/TemplateEngine/**
+
+ CI
+
diff --git a/test/UnitTests.proj b/test/UnitTests.proj
index a99d82d87543..9959c5ff9be7 100644
--- a/test/UnitTests.proj
+++ b/test/UnitTests.proj
@@ -28,9 +28,18 @@
-
+
+
+
+
+
diff --git a/test/dotnet-format.IntegrationTests/FormatIntegrationTestBase.cs b/test/dotnet-format.IntegrationTests/FormatIntegrationTestBase.cs
new file mode 100644
index 000000000000..7bb688319e9e
--- /dev/null
+++ b/test/dotnet-format.IntegrationTests/FormatIntegrationTestBase.cs
@@ -0,0 +1,311 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Text.RegularExpressions;
+
+namespace Microsoft.DotNet.Format.IntegrationTests;
+
+///
+/// Base class for dotnet-format integration tests. Each derived class targets a specific
+/// repository and gets its own clone+restore lifecycle via .
+/// Two test methods (workspace format and folder format) are inherited automatically.
+///
+public abstract class FormatIntegrationTestBase : SdkTest
+{
+ private static readonly TimeSpan ProcessTimeout = TimeSpan.FromMinutes(30);
+
+ // Keyed by concrete type so each derived class gets its own entry.
+ // MSTest creates a new instance per test method, so clone+restore would otherwise
+ // run once per test method rather than once per class per process.
+ private static readonly Dictionary s_initializedRepoPaths = new();
+
+ private string? _repoPath;
+
+ private string ParentDotNetPath => SdkTestContext.Current.ToolsetUnderTest.DotNetHostPath;
+
+ /// The full GitHub URL of the repository (e.g. "https://github.com/dotnet/sdk").
+ protected abstract string RepoUrl { get; }
+
+ /// The commit SHA to test against.
+ protected abstract string Sha { get; }
+
+ /// The solution/slnf/slnx file name to format (e.g. "sdk.slnx").
+ protected abstract string TargetSolution { get; }
+
+ /// Short display name for the repo (e.g. "sdk"). Used for filtering and logging.
+ protected abstract string RepoName { get; }
+
+ [TestInitialize]
+ public void TestInitialize()
+ {
+ if (s_initializedRepoPaths.TryGetValue(GetType(), out var cachedPath))
+ {
+ _repoPath = cachedPath;
+ Log.WriteLine($"Reusing initialized repo for {RepoName} at {_repoPath}");
+ return;
+ }
+
+ _repoPath = Path.Combine(Path.GetTempPath(), "dotnet-format-tests", RepoName);
+
+ if (IsAlreadyAtCorrectSha())
+ {
+ Log.WriteLine($"Reusing existing clone at {Sha}");
+ }
+ else
+ {
+ if (Directory.Exists(_repoPath))
+ {
+ Directory.Delete(_repoPath, recursive: true);
+ }
+
+ Directory.CreateDirectory(_repoPath);
+
+ CloneRepo();
+
+ // Run the repo's Arcade build script restore while global.json is still present.
+ // This installs the Arcade SDK tooling that project files import (e.g.,
+ // $(ArcadeSdkBuildTasksAssembly)). Without this, repos like Roslyn fail to restore
+ // because their project files have hard imports of Arcade targets.
+ RunArcadeBuildScriptRestore();
+ }
+
+ // Strip the "sdk" section from global.json so that the SDK under test is used
+ // for solution restore and formatting. The "msbuild-sdks" section is preserved
+ // so that NuGet SDK resolver can still find Arcade and other MSBuild SDKs.
+ RemoveGlobalJsonSdkSection();
+
+ Restore();
+
+ s_initializedRepoPaths[GetType()] = _repoPath;
+ }
+
+ [TestMethod]
+ public void FormatWorkspace()
+ {
+ var solutionPath = FindSolution();
+ Log.WriteLine($"Formatting workspace: {solutionPath}");
+
+ var result = new DotnetCommand(Log, "format", solutionPath, "--no-restore", "--verify-no-changes", "--verbosity", "detailed")
+ .WithWorkingDirectory(Path.GetDirectoryName(solutionPath)!)
+ .Execute();
+
+ // Exit code 0 = no changes needed, 2 = format differences found (expected for external repos).
+ // Any other exit code is an actual failure.
+ Assert.IsTrue(result.ExitCode == 0 || result.ExitCode == 2,
+ $"dotnet format exited with unexpected code {result.ExitCode}.\n{result.StdErr}");
+
+ // Validate that dotnet format actually processed files. Without this check, a broken
+ // restore can cause format to silently exit 0 having loaded zero projects.
+ var match = Regex.Match(result.StdOut ?? "", @"Formatted \d+ of (\d+) files");
+ Assert.IsTrue(match.Success && int.Parse(match.Groups[1].Value) > 0,
+ $"dotnet format did not report processing any files. Restore may have failed.\nStdOut: {result.StdOut}");
+ }
+
+ [TestMethod]
+ public void FormatFolder()
+ {
+ Log.WriteLine($"Formatting folder: {_repoPath}");
+
+ var result = new DotnetCommand(Log, "format", "whitespace", _repoPath!, "--folder", "--verify-no-changes", "--verbosity", "detailed")
+ .WithWorkingDirectory(_repoPath!)
+ .Execute();
+
+ Assert.IsTrue(result.ExitCode == 0 || result.ExitCode == 2,
+ $"dotnet format exited with unexpected code {result.ExitCode}.\n{result.StdErr}");
+
+ var match = Regex.Match(result.StdOut ?? "", @"Formatted \d+ of (\d+) files");
+ Assert.IsTrue(match.Success && int.Parse(match.Groups[1].Value) > 0,
+ $"dotnet format did not report processing any files.\nStdOut: {result.StdOut}");
+ }
+
+ private bool IsAlreadyAtCorrectSha()
+ {
+ if (!Directory.Exists(_repoPath))
+ {
+ return false;
+ }
+
+ var result = RunProcess("git", "rev-parse HEAD", ignoreThrowingOnError: true);
+ return result.ExitStatus.ExitCode == 0 &&
+ string.Equals(result.StandardOutput.Trim(), Sha, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private void CloneRepo()
+ {
+ Log.WriteLine($"Cloning {RepoUrl} at {Sha}...");
+ var sw = Stopwatch.StartNew();
+
+ RunProcess("git", "init");
+ RunProcess("git", $"remote add origin {RepoUrl}");
+ RunProcess("git", $"fetch --progress --no-tags --depth=1 origin {Sha}");
+ RunProcess("git", $"checkout {Sha}");
+
+ sw.Stop();
+ Log.WriteLine($"Clone completed in {sw.Elapsed:hh\\:mm\\:ss\\.ff}");
+ }
+
+ ///
+ /// Runs the repo's Arcade build script with -restore to install Arcade SDK tooling.
+ /// This must run while global.json is still present so that Arcade's install scripts
+ /// can acquire the correct SDK version. The installed tooling (targets, tasks) is then
+ /// available for the subsequent solution-level restore.
+ /// Failures are non-fatal — repos without Arcade (or with inaccessible feeds) still
+ /// proceed to the direct solution restore.
+ ///
+ private void RunArcadeBuildScriptRestore()
+ {
+ string scriptPath;
+ string arguments;
+
+ if (OperatingSystem.IsWindows())
+ {
+ var engBuild = Path.Combine(_repoPath!, "eng", "Build.ps1");
+ var engCommonBuild = Path.Combine(_repoPath!, "eng", "common", "Build.ps1");
+
+ if (File.Exists(engBuild))
+ {
+ scriptPath = "powershell";
+ arguments = $"-NoProfile -ExecutionPolicy Bypass -File \"{engBuild}\" -restore";
+ }
+ else if (File.Exists(engCommonBuild))
+ {
+ scriptPath = "powershell";
+ arguments = $"-NoProfile -ExecutionPolicy Bypass -File \"{engCommonBuild}\" -restore";
+ }
+ else
+ {
+ Log.WriteLine("No Arcade build script found; skipping Arcade restore.");
+ return;
+ }
+ }
+ else
+ {
+ var engBuildSh = Path.Combine(_repoPath!, "eng", "build.sh");
+ var engCommonBuildSh = Path.Combine(_repoPath!, "eng", "common", "build.sh");
+
+ if (File.Exists(engBuildSh))
+ {
+ scriptPath = "bash";
+ arguments = $"\"{engBuildSh}\" --restore";
+ }
+ else if (File.Exists(engCommonBuildSh))
+ {
+ scriptPath = "bash";
+ arguments = $"\"{engCommonBuildSh}\" --restore";
+ }
+ else
+ {
+ Log.WriteLine("No Arcade build script found; skipping Arcade restore.");
+ return;
+ }
+ }
+
+ Log.WriteLine($"Running Arcade build script restore: {scriptPath} {arguments}");
+ var sw = Stopwatch.StartNew();
+
+ RunProcess(scriptPath, arguments, ignoreThrowingOnError: true);
+
+ sw.Stop();
+ Log.WriteLine($"Arcade restore completed in {sw.Elapsed:hh\\:mm\\:ss\\.ff}");
+ }
+
+ ///
+ /// Strips the "sdk" section from the repo's global.json so that the SDK under test
+ /// is used for restore and formatting, while preserving the "msbuild-sdks" section
+ /// that tells the NuGet SDK resolver which versions of MSBuild SDKs
+ /// (e.g., Microsoft.DotNet.Arcade.Sdk) to use.
+ ///
+ private void RemoveGlobalJsonSdkSection()
+ {
+ var globalJsonPath = Path.Combine(_repoPath!, "global.json");
+ if (!File.Exists(globalJsonPath))
+ {
+ return;
+ }
+
+ var json = JsonNode.Parse(File.ReadAllText(globalJsonPath));
+ if (json is JsonObject obj && obj.ContainsKey("sdk"))
+ {
+ obj.Remove("sdk");
+ File.WriteAllText(globalJsonPath, obj.ToJsonString(new JsonSerializerOptions { WriteIndented = true }));
+ Log.WriteLine("Removed 'sdk' section from global.json to use parent SDK.");
+ }
+ }
+
+ private void Restore()
+ {
+ var sw = Stopwatch.StartNew();
+
+ var solutionPath = FindSolution();
+ RestoreWithParentSdk(solutionPath);
+
+ sw.Stop();
+ Log.WriteLine($"Restore completed in {sw.Elapsed:hh\\:mm\\:ss\\.ff}");
+ }
+
+ ///
+ /// Restores the solution using the SDK under test's dotnet.
+ /// NuGet auto-discovers NuGet.config by walking up from the solution file's directory,
+ /// so no explicit --configfile is needed.
+ /// Failures are logged but not fatal — some feeds may require authentication that
+ /// is unavailable in public CI environments.
+ ///
+ private void RestoreWithParentSdk(string solutionPath)
+ {
+ var solutionDir = Path.GetDirectoryName(solutionPath)!;
+ Log.WriteLine($"Restoring {Path.GetFileName(solutionPath)} with SDK under test: {ParentDotNetPath}");
+
+ RunProcess(ParentDotNetPath, $"restore \"{solutionPath}\"", ignoreThrowingOnError: true, workingDirectory: solutionDir);
+ }
+
+ ///
+ /// Runs a process and captures its stdout and stderr. Throws on timeout.
+ /// When is false (default), also throws on non-zero exit code.
+ ///
+ private ProcessTextOutput RunProcess(string fileName, string arguments, bool ignoreThrowingOnError = false, string? workingDirectory = null)
+ {
+ var psi = new ProcessStartInfo
+ {
+ FileName = fileName,
+ Arguments = arguments,
+ WorkingDirectory = workingDirectory ?? _repoPath!,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ };
+
+ var result = Process.RunAndCaptureText(psi, ProcessTimeout);
+
+ if (result.ExitStatus.ExitCode != 0)
+ {
+ Log.WriteLine(result.StandardOutput);
+ Log.WriteLine(result.StandardError);
+
+ if (!ignoreThrowingOnError)
+ {
+ throw new InvalidOperationException($"Command '{fileName} {arguments}' exited with code {result.ExitStatus.ExitCode}");
+ }
+
+ Log.WriteLine($"Warning: Command '{fileName} {arguments}' exited with code {result.ExitStatus.ExitCode} (non-fatal).");
+ }
+
+ return result;
+ }
+
+ private string FindSolution()
+ {
+ // Match the old script's -Recurse -Depth 2 behavior to avoid unnecessary deep traversal.
+ var options = new EnumerationOptions { RecurseSubdirectories = true, MaxRecursionDepth = 2 };
+ var matches = Directory.GetFiles(_repoPath!, TargetSolution, options);
+ Assert.IsNotEmpty(matches, $"Target solution '{TargetSolution}' not found under {_repoPath}");
+
+ if (matches.Length > 1)
+ {
+ Log.WriteLine($"Warning: multiple matches for '{TargetSolution}' under {_repoPath}: {string.Join(", ", matches)}. Using root-most match.");
+ }
+
+ return matches.MinBy(m => m.Length)!;
+ }
+}
diff --git a/test/dotnet-format.IntegrationTests/MsBuildFormatTests.cs b/test/dotnet-format.IntegrationTests/MsBuildFormatTests.cs
new file mode 100644
index 000000000000..9525954bc031
--- /dev/null
+++ b/test/dotnet-format.IntegrationTests/MsBuildFormatTests.cs
@@ -0,0 +1,19 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.DotNet.Format.IntegrationTests;
+
+///
+/// Format integration tests for the dotnet/msbuild repository.
+///
+[TestClass]
+public class MsBuildFormatTests : FormatIntegrationTestBase
+{
+ // Commit SHA aligned with .NET 10.0 GA release (v10.0.100).
+ // See https://github.com/dotnet/dotnet/blob/v10.0.100/src/source-manifest.json
+
+ protected override string RepoUrl => "https://github.com/dotnet/msbuild";
+ protected override string Sha => "995a3dce41788caebf2b8ca6602a7431f08bfd06";
+ protected override string TargetSolution => "MSBuild.sln";
+ protected override string RepoName => "msbuild";
+}
diff --git a/test/dotnet-format.IntegrationTests/ProjectSystemFormatTests.cs b/test/dotnet-format.IntegrationTests/ProjectSystemFormatTests.cs
new file mode 100644
index 000000000000..4cd0c08bbd7f
--- /dev/null
+++ b/test/dotnet-format.IntegrationTests/ProjectSystemFormatTests.cs
@@ -0,0 +1,19 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.DotNet.Format.IntegrationTests;
+
+///
+/// Format integration tests for the dotnet/project-system repository.
+///
+[TestClass]
+public class ProjectSystemFormatTests : FormatIntegrationTestBase
+{
+ // Commit SHA aligned with .NET 10.0 GA release (v10.0.100).
+ // See https://github.com/dotnet/dotnet/blob/v10.0.100/src/source-manifest.json
+
+ protected override string RepoUrl => "https://github.com/dotnet/project-system";
+ protected override string Sha => "e660d54d6b3198751bd0502fe270e1657f32a913";
+ protected override string TargetSolution => "ProjectSystem.sln";
+ protected override string RepoName => "project-system";
+}
diff --git a/test/dotnet-format.IntegrationTests/SdkFormatTests.cs b/test/dotnet-format.IntegrationTests/SdkFormatTests.cs
new file mode 100644
index 000000000000..bf2424f3a5f5
--- /dev/null
+++ b/test/dotnet-format.IntegrationTests/SdkFormatTests.cs
@@ -0,0 +1,19 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.DotNet.Format.IntegrationTests;
+
+///
+/// Format integration tests for the dotnet/sdk repository.
+///
+[TestClass]
+public class SdkFormatTests : FormatIntegrationTestBase
+{
+ // Commit SHA aligned with .NET 10.0 GA release (v10.0.100).
+ // See https://github.com/dotnet/dotnet/blob/v10.0.100/src/source-manifest.json
+
+ protected override string RepoUrl => "https://github.com/dotnet/sdk";
+ protected override string Sha => "e6bc966cc3d1348265b0831c6daca23267169d8f";
+ protected override string TargetSolution => "sdk.slnx";
+ protected override string RepoName => "sdk";
+}
diff --git a/test/dotnet-format.IntegrationTests/dotnet-format.IntegrationTests.csproj b/test/dotnet-format.IntegrationTests/dotnet-format.IntegrationTests.csproj
new file mode 100644
index 000000000000..31f430a5c236
--- /dev/null
+++ b/test/dotnet-format.IntegrationTests/dotnet-format.IntegrationTests.csproj
@@ -0,0 +1,13 @@
+
+
+
+ $(SdkTargetFramework)
+
+ None
+
+
+
+
+
+
+