diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 12bdf560d..fe238c532 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -1,162 +1,305 @@ -name: VFS for Git - -on: - pull_request: - branches: [ master, releases/shipped ] - push: - branches: [ master, releases/shipped ] - workflow_dispatch: - inputs: - git_version: - description: 'Microsoft Git version tag to include in the build (leave empty for default)' - required: false - type: string - -env: - GIT_VERSION: ${{ github.event.inputs.git_version || 'v2.50.1.vfs.0.1' }} - -jobs: - validate: - runs-on: windows-2025 - name: Validation - steps: - - name: Checkout source - uses: actions/checkout@v5 - - - name: Validate Microsoft Git version - shell: pwsh - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - & "$env:GITHUB_WORKSPACE\.github\workflows\scripts\validate_release.ps1" ` - -Repository microsoft/git ` - -Tag $env:GIT_VERSION && ` - Write-Host ::notice title=Validation::Using microsoft/git version $env:GIT_VERSION - - build: - runs-on: windows-2025 - name: Build and Unit Test - needs: validate - - strategy: - matrix: - configuration: [ Debug, Release ] - - steps: - - name: Checkout source - uses: actions/checkout@v5 - with: - path: src - - - name: Install .NET SDK - uses: actions/setup-dotnet@v5 - with: - dotnet-version: 8.0.413 - - - name: Add MSBuild to PATH - uses: microsoft/setup-msbuild@v2.0.0 - - - name: Build VFS for Git - shell: cmd - run: src\scripts\Build.bat ${{ matrix.configuration }} - - - name: Run unit tests - shell: cmd - run: src\scripts\RunUnitTests.bat ${{ matrix.configuration }} - - - name: Create build artifacts - shell: cmd - run: src\scripts\CreateBuildArtifacts.bat ${{ matrix.configuration }} artifacts - - - name: Download microsoft/git installers - shell: cmd - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh release download %GIT_VERSION% --repo microsoft/git --pattern "Git*.exe" --dir artifacts\GVFS.Installers - - - name: Upload functional tests drop - uses: actions/upload-artifact@v4 - with: - name: FunctionalTests_${{ matrix.configuration }} - path: artifacts\GVFS.FunctionalTests - - - name: Upload FastFetch drop - uses: actions/upload-artifact@v4 - with: - name: FastFetch_${{ matrix.configuration }} - path: artifacts\FastFetch - - - name: Upload installers - uses: actions/upload-artifact@v4 - with: - name: Installers_${{ matrix.configuration }} - path: artifacts\GVFS.Installers - - functional_test: - runs-on: ${{ matrix.architecture == 'arm64' && 'windows-11-arm' || 'windows-2025' }} - name: Functional Tests - needs: build - - strategy: - matrix: - configuration: [ Debug, Release ] - architecture: [ x86_64, arm64 ] - - steps: - - name: Download installers - uses: actions/download-artifact@v5 - with: - name: Installers_${{ matrix.configuration }} - path: install - - - name: Download functional tests drop - uses: actions/download-artifact@v5 - with: - name: FunctionalTests_${{ matrix.configuration }} - path: ft - - - name: ProjFS details (pre-install) - shell: cmd - run: install\info.bat - - - name: Install product - shell: cmd - run: install\install.bat - - - name: ProjFS details (post-install) - shell: cmd - run: install\info.bat - - - name: Upload installation logs - if: always() - uses: actions/upload-artifact@v4 - with: - name: InstallationLogs_${{ matrix.configuration }}_${{ matrix.architecture }} - path: install\logs - - - name: Run functional tests - shell: cmd - run: | - SET PATH=C:\Program Files\VFS for Git;%PATH% - SET GIT_TRACE2_PERF=C:\temp\git-trace2.log - ft\GVFS.FunctionalTests.exe /result:TestResult.xml --ci - - - name: Upload functional test results - if: always() - uses: actions/upload-artifact@v4 - with: - name: FunctionalTests_Results_${{ matrix.configuration }}_${{ matrix.architecture }} - path: TestResult.xml - - - name: Upload Git trace2 output - if: always() - uses: actions/upload-artifact@v4 - with: - name: GitTrace2_${{ matrix.configuration }}_${{ matrix.architecture }} - path: C:\temp\git-trace2.log - - - name: ProjFS details (post-test) - if: always() - shell: cmd - run: install\info.bat +name: VFS for Git + +on: + pull_request: + branches: [ master, releases/shipped ] + push: + branches: [ master, releases/shipped ] + workflow_dispatch: + inputs: + git_version: + description: 'Microsoft Git version tag to include in the build (leave empty for default)' + required: false + type: string + +permissions: + contents: read + +env: + GIT_VERSION: ${{ github.event.inputs.git_version || 'v2.50.1.vfs.0.1' }} + +jobs: + validate: + runs-on: windows-2025 + name: Validation + outputs: + skip: ${{ steps.check.outputs.result }} + + steps: + - name: Look for prior successful runs + id: check + if: github.event.inputs.git_version == '' + uses: actions/github-script@v7 + with: + github-token: ${{secrets.GITHUB_TOKEN}} + result-encoding: string + script: | + /* + * It would be nice if GitHub Actions offered a convenient way to avoid running + * successful workflow runs _again_ for the respective commit (or for a tree-same one): + * We would expect the same outcome in those cases, right? + * + * Let's check for such a scenario: Look for previous runs that have been successful + * and that correspond to the same commit, or at least a tree-same one. If there is + * one, skip running the build and tests _again_. + * + * There are challenges, though: We need to require those _jobs_ to succeed before PRs + * can be merged. You can mark workflow _jobs_ as required on GitHub, but not + * _workflows_. So if those jobs are now simply skipped, the requirement isn't met and + * the PR cannot be merged. We can't just skip the job. Instead, we need to run the job + * _but skip every single step_ so that the job can "succeed". + */ + try { + // Figure out workflow ID, commit and tree + const { data: run } = await github.rest.actions.getWorkflowRun({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: context.runId, + }); + const workflow_id = run.workflow_id; + const head_sha = run.head_sha; + const tree_id = run.head_commit.tree_id; + + // See whether there is a successful run for that commit or tree + const { data: runs } = await github.rest.actions.listWorkflowRuns({ + owner: context.repo.owner, + repo: context.repo.repo, + per_page: 500, + workflow_id, + }); + // first look at commit-same runs, then at finished ones, then at in-progress ones + const rank = (a) => (a.status === 'in_progress' ? 0 : (head_sha === a.head_sha ? 2 : 1)) + const demoteInProgressToEnd = (a, b) => (rank(b) - rank(a)) + for (const run of runs.workflow_runs.sort(demoteInProgressToEnd)) { + if (head_sha !== run.head_sha && tree_id !== run.head_commit?.tree_id) continue + if (context.runId === run.id) continue // do not wait for the current run to finish ;-) + if (run.event === 'workflow_dispatch') continue // skip runs that were started manually: they can override the Git version + + if (run.status === 'in_progress') { + // poll until the run is done + const pollIntervalInSeconds = 30 + let seconds = 0 + for (;;) { + console.log(`Found existing, in-progress run at ${run.html_url}; Waiting for it to finish (waited ${seconds} seconds so far)...`) + await new Promise((resolve) => { setTimeout(resolve, pollIntervalInSeconds * 1000) }) + seconds += pollIntervalInSeconds + + const { data: polledRun } = await github.rest.actions.getWorkflowRun({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: run.id + }) + if (polledRun.status !== 'in_progress') break + } + } + + if (run.status === 'completed' && run.conclusion === 'success') return run.html_url + } + return '' + } catch (e) { + core.error(e) + core.warning(e) + } + + - name: Checkout source + if: steps.check.outputs.result == '' + uses: actions/checkout@v5 + + - name: Validate Microsoft Git version + if: steps.check.outputs.result == '' + shell: pwsh + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + & "$env:GITHUB_WORKSPACE\.github\workflows\scripts\validate_release.ps1" ` + -Repository microsoft/git ` + -Tag $env:GIT_VERSION && ` + Write-Host ::notice title=Validation::Using microsoft/git version $env:GIT_VERSION + + build: + runs-on: windows-2025 + name: Build and Unit Test + needs: validate + + strategy: + matrix: + configuration: [ Debug, Release ] + fail-fast: false + + steps: + - name: Skip this job if there is a previous successful run + if: needs.validate.outputs.skip != '' + id: skip + uses: actions/github-script@v7 + with: + script: | + core.info(`Skipping: There already is a successful run: ${{ needs.validate.outputs.skip }}`) + return true + + - name: Checkout source + if: steps.skip.outputs.result != 'true' + uses: actions/checkout@v5 + with: + path: src + + - name: Install .NET SDK + if: steps.skip.outputs.result != 'true' + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 8.0.413 + + - name: Add MSBuild to PATH + if: steps.skip.outputs.result != 'true' + uses: microsoft/setup-msbuild@v2.0.0 + + - name: Build VFS for Git + if: steps.skip.outputs.result != 'true' + shell: cmd + run: src\scripts\Build.bat ${{ matrix.configuration }} + + - name: Run unit tests + if: steps.skip.outputs.result != 'true' + shell: cmd + run: src\scripts\RunUnitTests.bat ${{ matrix.configuration }} + + - name: Create build artifacts + if: steps.skip.outputs.result != 'true' + shell: cmd + run: src\scripts\CreateBuildArtifacts.bat ${{ matrix.configuration }} artifacts + + - name: Download microsoft/git installers + if: steps.skip.outputs.result != 'true' + shell: cmd + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release download %GIT_VERSION% --repo microsoft/git --pattern "Git*.exe" --dir artifacts\GVFS.Installers + + - name: Upload functional tests drop + if: steps.skip.outputs.result != 'true' + uses: actions/upload-artifact@v4 + with: + name: FunctionalTests_${{ matrix.configuration }} + path: artifacts\GVFS.FunctionalTests + + - name: Upload FastFetch drop + if: steps.skip.outputs.result != 'true' + uses: actions/upload-artifact@v4 + with: + name: FastFetch_${{ matrix.configuration }} + path: artifacts\FastFetch + + - name: Upload installers + if: steps.skip.outputs.result != 'true' + uses: actions/upload-artifact@v4 + with: + name: Installers_${{ matrix.configuration }} + path: artifacts\GVFS.Installers + + functional_test: + runs-on: ${{ matrix.architecture == 'arm64' && 'windows-11-arm' || 'windows-2025' }} + name: Functional Tests + needs: [validate, build] + + strategy: + matrix: + configuration: [ Debug, Release ] + architecture: [ x86_64, arm64 ] + nr: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] # 10 parallel jobs to speed up the tests + fail-fast: false # most failures are flaky tests, no need to stop the other jobs from succeeding + + steps: + - name: Skip this job if there is a previous successful run + if: needs.validate.outputs.skip != '' + id: skip + uses: actions/github-script@v7 + with: + script: | + core.info(`Skipping: There already is a successful run: ${{ needs.validate.outputs.skip }}`) + return true + + - name: Download installers + if: steps.skip.outputs.result != 'true' + uses: actions/download-artifact@v5 + with: + name: Installers_${{ matrix.configuration }} + path: install + + - name: Download functional tests drop + if: steps.skip.outputs.result != 'true' + uses: actions/download-artifact@v5 + with: + name: FunctionalTests_${{ matrix.configuration }} + path: ft + + - name: ProjFS details (pre-install) + if: steps.skip.outputs.result != 'true' + shell: cmd + run: install\info.bat + + - name: Install product + if: steps.skip.outputs.result != 'true' + shell: cmd + run: install\install.bat + + - name: ProjFS details (post-install) + if: steps.skip.outputs.result != 'true' + shell: cmd + run: install\info.bat + + - name: Upload installation logs + if: always() && steps.skip.outputs.result != 'true' + uses: actions/upload-artifact@v4 + with: + name: InstallationLogs_${{ matrix.configuration }}_${{ matrix.architecture }}-${{ matrix.nr }} + path: install\logs + + - name: Run functional tests + if: steps.skip.outputs.result != 'true' + shell: cmd + run: | + SET PATH=C:\Program Files\VFS for Git;%PATH% + SET GIT_TRACE2_PERF=C:\temp\git-trace2.log + ft\GVFS.FunctionalTests.exe /result:TestResult.xml --ci --slice=${{ matrix.nr }},10 + + - name: Upload functional test results + if: always() && steps.skip.outputs.result != 'true' + uses: actions/upload-artifact@v4 + with: + name: FunctionalTests_Results_${{ matrix.configuration }}_${{ matrix.architecture }}-${{ matrix.nr }} + path: TestResult.xml + + - name: Upload Git trace2 output + if: always() && steps.skip.outputs.result != 'true' + uses: actions/upload-artifact@v4 + with: + name: GitTrace2_${{ matrix.configuration }}_${{ matrix.architecture }}-${{ matrix.nr }} + path: C:\temp\git-trace2.log + + - name: ProjFS details (post-test) + if: always() && steps.skip.outputs.result != 'true' + shell: cmd + run: install\info.bat + + ft_results: + runs-on: ubuntu-latest # quickest runners + name: Functional Tests + needs: [functional_test] + + strategy: + matrix: + configuration: [ Debug, Release ] + architecture: [ x86_64, arm64 ] + + steps: + - name: Success! # for easier identification of successful runs in the Checks Required for Pull Requests + run: echo "All functional test jobs successful for ${{ matrix.configuration }} / ${{ matrix.architecture }}!" + + result: + runs-on: ubuntu-latest + name: Build, Unit and Functional Tests Successful + needs: [functional_test] + + steps: + - name: Success! # for easier identification of successful runs in the Checks Required for Pull Requests + run: echo "Workflow run is successful!" \ No newline at end of file diff --git a/Directory.Build.targets b/Directory.Build.targets index 94263857e..578902043 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -3,6 +3,12 @@ $(GVFSVersion) + + + false diff --git a/GVFS/GVFS.Common/ProcessHelper.cs b/GVFS/GVFS.Common/ProcessHelper.cs index ad24a6434..4fa57fbaf 100644 --- a/GVFS/GVFS.Common/ProcessHelper.cs +++ b/GVFS/GVFS.Common/ProcessHelper.cs @@ -58,17 +58,7 @@ public static string GetCurrentProcessVersion() public static bool IsDevelopmentVersion() { string version = ProcessHelper.GetCurrentProcessVersion(); - /* When debugging local version with VS, the version will include +{commitId} suffix, - * which is not valid for Version class. */ - var plusIndex = version.IndexOf('+'); - if (plusIndex >= 0) - { - version = version.Substring(0, plusIndex); - } - - Version currentVersion = new Version(version); - - return currentVersion.Major == 0; + return version.Equals("0.2.173.2") || version.StartsWith("0.2.173.2+"); } public static string GetProgramLocation(string programLocaterCommand, string processName) diff --git a/GVFS/GVFS.FunctionalTests/Program.cs b/GVFS/GVFS.FunctionalTests/Program.cs index 276a7d701..f00d9496a 100644 --- a/GVFS/GVFS.FunctionalTests/Program.cs +++ b/GVFS/GVFS.FunctionalTests/Program.cs @@ -89,6 +89,25 @@ public static void Main(string[] args) GVFSTestConfig.FileSystemRunners = FileSystemRunners.FileSystemRunner.DefaultRunners; } + (uint, uint)? testSlice = null; + string testSliceArg = runner.GetCustomArgWithParam("--slice"); + if (testSliceArg != null) + { + // split `testSliceArg` on a comma and parse the two values as uints + string[] parts = testSliceArg.Split(','); + uint sliceNumber; + uint totalSlices; + if (parts.Length != 2 || + !uint.TryParse(parts[0], out sliceNumber) || + !uint.TryParse(parts[1], out totalSlices) || + totalSlices == 0 || + sliceNumber >= totalSlices) + { + throw new Exception("Invalid argument to --slice. Expected format: X,Y where X is the slice number and Y is the total number of slices"); + } + testSlice = (sliceNumber, totalSlices); + } + GVFSTestConfig.DotGVFSRoot = ".gvfs"; GVFSTestConfig.RepoToClone = @@ -96,7 +115,7 @@ public static void Main(string[] args) ?? Properties.Settings.Default.RepoToClone; RunBeforeAnyTests(); - Environment.ExitCode = runner.RunTests(includeCategories, excludeCategories); + Environment.ExitCode = runner.RunTests(includeCategories, excludeCategories, testSlice); if (Debugger.IsAttached) { diff --git a/GVFS/GVFS.Tests/NUnitRunner.cs b/GVFS/GVFS.Tests/NUnitRunner.cs index 1f1e3e99c..e0861eea9 100644 --- a/GVFS/GVFS.Tests/NUnitRunner.cs +++ b/GVFS/GVFS.Tests/NUnitRunner.cs @@ -1,8 +1,10 @@ using NUnitLite; using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Reflection; +using System.Text.RegularExpressions; namespace GVFS.Tests { @@ -42,10 +44,106 @@ public void AddGlobalSetupIfNeeded(string globalSetup) } } - public int RunTests(ICollection includeCategories, ICollection excludeCategories) + public void PrepareTestSlice(string filters, (uint, uint) testSlice) { - string filters = GetFiltersArgument(includeCategories, excludeCategories); + IEnumerable args = this.args.Concat(new[] { "--explore" }); if (filters.Length > 0) + { + args = args.Concat(new[] { "--where", filters }); + } + + // Temporarily redirect Console.Out to capture the output of --explore + var stringWriter = new StringWriter(); + var originalOut = Console.Out; + + string[] list; + try + { + Console.SetOut(stringWriter); + int exploreResult = new AutoRun(Assembly.GetEntryAssembly()).Execute(args.ToArray()); + if (exploreResult != 0) + { + throw new Exception("--explore failed with " + exploreResult); + } + + list = stringWriter.ToString().Split(new[] { "\n" }, StringSplitOptions.None); + } + finally + { + Console.SetOut(originalOut); // Ensure we restore Console.Out + } + + // Sort the test cases into roughly equal-sized buckets; + // Care must be taken to ensure that all test cases for a given + // EnlistmentPerFixture class go into the same bucket, as they + // may very well be dependent on each other. + + // First, create the buckets + List[] buckets = new List[testSlice.Item2]; + // There is no PriorityQueue in .NET Framework; Emulate one via + // a sorted set that contains tuples of (bucket index, bucket size). + var priorityQueue = new SortedSet<(int, int)>( + Comparer<(int, int)>.Create((x, y) => + { + if (x.Item2 != y.Item2) + { + return x.Item2.CompareTo(y.Item2); + } + return x.Item1.CompareTo(y.Item1); + })); + for (int i = 0; i < buckets.Length; i++) + { + buckets[i] = new List(); + priorityQueue.Add((i, buckets[i].Count)); + } + + // Now distribute the tests into the buckets + Regex perFixtureRegex = new Regex( + @"^.*\.EnlistmentPerFixture\..+\.", + // @"^.*\.", + RegexOptions.CultureInvariant | RegexOptions.IgnoreCase); + for (uint i = 0; i < list.Length; i++) + { + var test = list[i].Trim(); + if (!test.StartsWith("GVFS.")) continue; + + var bucket = priorityQueue.Min; + priorityQueue.Remove(bucket); + + buckets[bucket.Item1].Add(test); + + // Ensure that EnlistmentPerFixture tests of the same class are all in the same bucket + var match = perFixtureRegex.Match(test); + if (match.Success) + { + string prefix = match.Value; + while (i + 1 < list.Length && list[i + 1].StartsWith(prefix)) + { + buckets[bucket.Item1].Add(list[++i].Trim()); + } + } + + bucket.Item2 = buckets[bucket.Item1].Count; + priorityQueue.Add(bucket); + } + + // Write the respective bucket's contents to a file + string listFile = $"GVFS_test_slice_{testSlice.Item1}_of_{testSlice.Item2}.txt"; + File.WriteAllLines(listFile, buckets[testSlice.Item1]); + Console.WriteLine($"Wrote {buckets[testSlice.Item1].Count} test cases to {listFile}"); + + this.args.Add($"--testlist={listFile}"); + } + + public int RunTests(ICollection includeCategories, ICollection excludeCategories, (uint, uint)? testSlice = null) + { + string filters = GetFiltersArgument(includeCategories, excludeCategories); + + if (testSlice.HasValue && testSlice.Value.Item2 != 1) + { + this.PrepareTestSlice(filters, testSlice.Value); + } + else if (filters.Length > 0) { this.args.Add("--where"); this.args.Add(filters); diff --git a/GVFS/GVFS/CommandLine/GVFSVerb.cs b/GVFS/GVFS/CommandLine/GVFSVerb.cs index a9fcb9587..fa183c7a3 100644 --- a/GVFS/GVFS/CommandLine/GVFSVerb.cs +++ b/GVFS/GVFS/CommandLine/GVFSVerb.cs @@ -933,7 +933,11 @@ private bool TryValidateGVFSVersion(GVFSEnlistment enlistment, ITracer tracer, S return true; } - Version currentVersion = new Version(ProcessHelper.GetCurrentProcessVersion()); + string recordedVersion = ProcessHelper.GetCurrentProcessVersion(); + // Work around the default behavior in .NET SDK 8 where the revision ID + // is appended after a '+' character, which cannot be parsed by `System.Version`. + int plus = recordedVersion.IndexOf('+'); + Version currentVersion = new Version(plus < 0 ? recordedVersion : recordedVersion.Substring(0, plus)); IEnumerable allowedGvfsClientVersions = config != null ? config.AllowedGVFSClientVersions