diff --git a/.azure-pipelines/release.yml b/.azure-pipelines/release.yml index f754ce9d1..c7454f322 100644 --- a/.azure-pipelines/release.yml +++ b/.azure-pipelines/release.yml @@ -1,93 +1,95 @@ -# NOTE: this pipeline definition is not currently used to build releases of VFS for Git. -# This is still done in the GVFS-Release-RealSign "classic" pipeline. - name: $(date:yy)$(DayOfYear)$(rev:.r) +trigger: none +pr: none variables: - signType: test - teamName: GVFS - configuration: Release - signPool: VSEng-MicroBuildVS2019 - GVFSMajorAndMinorVersion: 1.0 + GVFSMajorAndMinorVersion: 2.0 GVFSRevision: $(Build.BuildNumber) - -jobs: -- job: build - displayName: Windows Build and Sign - - pool: - name: $(signPool) - - steps: - - task: ms-vseng.MicroBuildTasks.30666190-6959-11e5-9f96-f56098202fef.MicroBuildSigningPlugin@2 - displayName: Install signing plugin - inputs: - signType: '$(SignType)' - - - task: UseDotNet@2 - displayName: Install .NET SDK - inputs: - packageType: sdk - version: 8.0.413 - - - task: CmdLine@2 - displayName: Build VFS for Git - inputs: - script: $(Build.Repository.LocalPath)\scripts\Build.bat $(configuration) $(GVFSMajorAndMinorVersion).$(GVFSRevision) detailed - - - task: CmdLine@2 - displayName: Run unit tests - inputs: - script: $(Build.Repository.LocalPath)\scripts\RunUnitTests.bat $(configuration) - - - task: CmdLine@2 - displayName: Create build artifacts - inputs: - script: $(Build.Repository.LocalPath)\scripts\CreateBuildArtifacts.bat $(configuration) $(Build.ArtifactStagingDirectory) - - - task: PublishBuildArtifacts@1 - displayName: 'Publish Artifact: Installer' - inputs: - PathtoPublish: $(Build.ArtifactStagingDirectory)\NuGetPackages - ArtifactName: Installer - - - task: PublishBuildArtifacts@1 - displayName: 'Publish Artifact: FastFetch' - inputs: - PathtoPublish: $(Build.ArtifactStagingDirectory)\FastFetch - ArtifactName: FastFetch - - - task: PublishSymbols@1 - displayName: Enable Source Server - condition: eq(succeeded(), eq(variables['signType'], 'real')) - inputs: - SearchPattern: '**\*.pdb' - SymbolsFolder: $(Build.ArtifactStagingDirectory)\Symbols - - - task: PublishBuildArtifacts@1 - displayName: 'Publish Artifact: Symbols' - inputs: - PathtoPublish: $(Build.ArtifactStagingDirectory)\Symbols - ArtifactName: Symbols - - - task: ms-vscs-artifact.build-tasks.artifactSymbolTask-1.artifactSymbolTask@0 - displayName: Publish to Symbols on Symweb - condition: eq(succeeded(), eq(variables['signType'], 'real')) - inputs: - symbolServiceURI: https://microsoft.artifacts.visualstudio.com/DefaultCollection - sourcePath: $(Build.ArtifactStagingDirectory)/Symbols - expirationInDays: 2065 - usePat: false - - - task: NuGetCommand@2 - displayName: Push GVFS.Installers package - condition: eq(succeeded(), eq(variables['signType'], 'real')) - inputs: - command: push - packagesToPush: $(Build.ArtifactStagingDirectory)\NuGetPackages\GVFS.Installers.*.nupkg - nuGetFeedType: external - publishFeedCredentials: '1essharedassets GVFS [PUBLISH]' - - - task: ms-vseng.MicroBuildTasks.521a94ea-9e68-468a-8167-6dcf361ea776.MicroBuildCleanup@1 - displayName: Send MicroBuild Telemetry - condition: always() + BuildConfiguration: Release + TeamName: GVFS + +resources: + repositories: + - repository: MicroBuildTemplate + type: git + name: 1ESPipelineTemplates/MicroBuildTemplate + ref: refs/tags/release + + - repository: VFSForGit + type: github + name: microsoft/VFSForGit + ref: releases/shipped + endpoint: GitHub-VFSForGit + +extends: + template: azure-pipelines/MicroBuild.1ES.Official.yml@MicroBuildTemplate + parameters: + pool: + name: VSEngSS-MicroBuild2022-1ES + + featureFlags: + incrementalSDLBinaryAnalysis: false + disableNetworkIsolation: true + + sdl: + binskim: + enabled: false + justificationForDisabling: "Guardian and BinSkim do not support a suppression for InnoSetup installer file" + sourceRepositoriesToScan: + include: + - repository: VFSForGit + + stages: + - stage: Release + + jobs: + - job: Build + templateContext: + mb: + signing: + enabled: true + feedSource: 'https://pkgs.dev.azure.com/mseng/_packaging/MicroBuildToolset/nuget/v3/index.json' + signType: real + signWithProd: true + + outputs: + - output: pipelineArtifact + targetPath: $(Build.ArtifactStagingDirectory)\GVFS.Installers + artifactName: Installer + - output: pipelineArtifact + targetPath: $(Build.ArtifactStagingDirectory)\FastFetch + artifactName: FastFetch + - output: pipelineArtifact + targetPath: $(Build.ArtifactStagingDirectory)\Symbols + artifactName: Symbols + - output: pipelineArtifact + targetPath: $(Build.ArtifactStagingDirectory)\GVFS.FunctionalTests + artifactName: FunctionalTests + + steps: + - checkout: VFSForGit + displayName: 'Checkout VFS for Git' + path: vfsforgit\src + + - task: NuGetToolInstaller@1 + displayName: 'Use NuGet 6.x' + inputs: + versionSpec: '6.x' + + - script: | + $(Agent.BuildDirectory)\vfsforgit\src\scripts\Build.bat ^ + $(BuildConfiguration) ^ + $(GVFSMajorAndMinorVersion).$(GVFSRevision) ^ + detailed + displayName: 'Build and sign ($(BuildConfiguration))' + + - script: | + $(Agent.BuildDirectory)\vfsforgit\src\scripts\RunUnitTests.bat ^ + $(BuildConfiguration) + displayName: 'Run unit tests' + + - script: | + $(Agent.BuildDirectory)\vfsforgit\src\scripts\CreateBuildArtifacts.bat ^ + $(BuildConfiguration) ^ + $(Build.ArtifactStagingDirectory) + displayName: 'Create artifacts' diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 3bf1ad189..fba76511d 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -23,7 +23,7 @@ permissions: actions: read env: - GIT_VERSION: ${{ github.event.inputs.git_version || 'v2.53.0.vfs.0.6' }} + GIT_VERSION: ${{ github.event.inputs.git_version || 'v2.53.0.vfs.0.7' }} jobs: validate: @@ -36,7 +36,7 @@ jobs: - name: Look for prior successful runs id: check if: github.event.inputs.git_version == '' - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: github-token: ${{secrets.GITHUB_TOKEN}} result-encoding: string @@ -182,7 +182,7 @@ jobs: - name: Skip this job if there is a previous successful run if: needs.validate.outputs.skip != '' id: skip - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: script: | core.info(`Skipping: There already is a successful run: ${{ needs.validate.outputs.skip }}`) @@ -198,7 +198,7 @@ jobs: if: steps.skip.outputs.result != 'true' uses: actions/setup-dotnet@v5 with: - dotnet-version: 8.0.413 + global-json-file: src/global.json - name: Add MSBuild to PATH if: steps.skip.outputs.result != 'true' @@ -247,10 +247,17 @@ jobs: with: skip: ${{ needs.validate.outputs.skip }} + upgrade_tests: + name: Upgrade Tests + needs: [validate, build] + uses: ./.github/workflows/upgrade-tests.yaml + with: + skip: ${{ needs.validate.outputs.skip }} + result: runs-on: ubuntu-latest name: Build, Unit and Functional Tests Successful - needs: [functional_tests] + needs: [functional_tests, upgrade_tests] steps: - name: Success! # for easier identification of successful runs in the Checks Required for Pull Requests diff --git a/.github/workflows/functional-tests.yaml b/.github/workflows/functional-tests.yaml index 16f0988cf..72c9ee503 100644 --- a/.github/workflows/functional-tests.yaml +++ b/.github/workflows/functional-tests.yaml @@ -70,7 +70,7 @@ jobs: - name: Skip this job if there is a previous successful run if: inputs.skip != '' id: skip - uses: actions/github-script@v8 + uses: actions/github-script@v9 with: script: | core.info(`Skipping: There already is a successful run: ${{ inputs.skip }}`) @@ -122,6 +122,18 @@ jobs: shell: cmd run: gvfs\install.bat + - name: Verify GVFS installation + if: steps.skip.outputs.result != 'true' + shell: cmd + continue-on-error: true + run: | + echo === GVFS Version === + "C:\Program Files\VFS for Git\GVFS.exe" version + echo === Service Status === + sc query GVFS.Service + echo === List Mounted === + "C:\Program Files\VFS for Git\GVFS.exe" service --list-mounted + - name: ProjFS details (post-install) if: steps.skip.outputs.result != 'true' shell: cmd @@ -141,6 +153,7 @@ jobs: - name: Run functional tests if: steps.skip.outputs.result != 'true' shell: cmd + timeout-minutes: 60 run: | SET PATH=C:\Program Files\VFS for Git;%PATH% SET GIT_TRACE2_PERF=C:\temp\git-trace2.log diff --git a/.github/workflows/upgrade-tests.yaml b/.github/workflows/upgrade-tests.yaml new file mode 100644 index 000000000..44eaaac94 --- /dev/null +++ b/.github/workflows/upgrade-tests.yaml @@ -0,0 +1,290 @@ +name: Upgrade Tests + +on: + workflow_call: + inputs: + skip: + description: 'URL of a previous successful run; if non-empty, all steps are skipped' + required: false + type: string + default: '' + lkg_release_tag: + description: 'Tag of the last known good release to upgrade from (default: latest release)' + required: false + type: string + default: '' + +permissions: + contents: read + actions: read + +jobs: + upgrade_test: + runs-on: windows-2025 + name: Upgrade + timeout-minutes: 30 + + strategy: + matrix: + configuration: [ Debug ] + scenario: + - staging-upgrade + - clean-upgrade + - double-staging + - staging-then-clean + - mount-safety-deferral + fail-fast: false + + steps: + - name: Skip this job if there is a previous successful run + if: inputs.skip != '' + id: skip + uses: actions/github-script@v9 + with: + script: | + core.info(`Skipping: There already is a successful run: ${{ inputs.skip }}`) + return true + + # -- Artifacts -- + + - name: Download LKG release installer + if: steps.skip.outputs.result != 'true' + shell: pwsh + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + $tag = "${{ inputs.lkg_release_tag }}" + if (-not $tag) { + $tag = gh api repos/microsoft/VFSForGit/releases/latest --jq '.tag_name' + Write-Host "Auto-detected latest release: $tag" + } + New-Item -ItemType Directory -Path gvfs-lkg -Force | Out-Null + gh release download $tag --repo microsoft/VFSForGit --pattern "SetupGVFS*.exe" --dir gvfs-lkg + + - name: Download Git installer + if: steps.skip.outputs.result != 'true' + uses: actions/download-artifact@v8 + with: + name: MicrosoftGit + path: git + + - name: Download current GVFS installer + if: steps.skip.outputs.result != 'true' + uses: actions/download-artifact@v8 + with: + name: GVFS_${{ matrix.configuration }} + path: gvfs-new + + # -- Setup -- + + - name: Install Git + if: steps.skip.outputs.result != 'true' + shell: cmd + run: git\install.bat + + - name: Enable ProjFS + if: steps.skip.outputs.result != 'true' + shell: pwsh + run: | + $feature = Get-WindowsOptionalFeature -Online -FeatureName Client-ProjFS + if ($feature.State -ne 'Enabled') { + Enable-WindowsOptionalFeature -Online -FeatureName Client-ProjFS -NoRestart + } + + # -- Test Execution -- + + - name: Run upgrade test - ${{ matrix.scenario }} + if: steps.skip.outputs.result != 'true' + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + + $lkgInstaller = (Get-ChildItem gvfs-lkg\SetupGVFS*.exe).FullName + $newInstaller = (Get-ChildItem gvfs-new\SetupGVFS*.exe).FullName + $installDir = "C:\Program Files\VFS for Git" + $testRepo = "https://dev.azure.com/gvfs/ci/_git/ForTests" + $enlistment = "C:\gvfs-upgrade-test" + + function Install-GVFS($installer, [string[]]$extraArgs = @()) { + $logDir = "C:\temp\gvfs-install-logs" + New-Item -ItemType Directory -Path $logDir -Force | Out-Null + $logFile = Join-Path $logDir "gvfs-$(Get-Date -Format 'yyyyMMdd-HHmmss').log" + $allArgs = @("/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART", "/LOG=$logFile") + $extraArgs + Write-Host "Installing: $installer $($allArgs -join ' ')" + # Start without -Wait: Inno Setup launches child processes + # (e.g. GVFS.Service.UI) that stay running, causing -Wait to + # hang. Instead, wait only for the installer process itself. + $proc = Start-Process -FilePath $installer -ArgumentList $allArgs -PassThru + $proc.WaitForExit() + if ($proc.ExitCode -ne 0) { + Get-Content $logFile -Tail 30 -ErrorAction SilentlyContinue + throw "Installer failed with exit code $($proc.ExitCode)" + } + Write-Host "Installed successfully" + } + + function Assert-ServiceRunning { + $svc = sc.exe query GVFS.Service 2>&1 | Select-String "STATE" + if ($svc -notmatch "RUNNING") { throw "GVFS.Service is not running: $svc" } + } + + function Mount-TestRepo { + if (Test-Path $enlistment) { + & "$installDir\gvfs.exe" mount $enlistment 2>&1 | Write-Host + } else { + & "$installDir\gvfs.exe" clone $testRepo $enlistment 2>&1 | Write-Host + } + if ($LASTEXITCODE -ne 0) { throw "Mount/clone failed" } + $mountProc = Get-Process -Name "GVFS.Mount" -ErrorAction SilentlyContinue + if (-not $mountProc) { throw "No GVFS.Mount process after mount" } + return $mountProc.Id + } + + function Assert-MountAlive($expectedPid) { + $proc = Get-Process -Id $expectedPid -ErrorAction SilentlyContinue + if (-not $proc -or $proc.ProcessName -ne "GVFS.Mount") { + throw "Mount process $expectedPid is no longer running" + } + # Verify the mount is functional by accessing a file + $readmePath = Join-Path $enlistment "src\Readme.md" + if (-not (Test-Path $readmePath)) { + throw "Mount is running but cannot access $readmePath" + } + } + + function Unmount-TestRepo { + & "$installDir\gvfs.exe" unmount $enlistment 2>&1 + Start-Sleep -Seconds 3 + } + + function Restart-Service { + sc.exe stop GVFS.Service | Out-Null + Start-Sleep -Seconds 10 + sc.exe start GVFS.Service | Out-Null + Start-Sleep -Seconds 10 + Assert-ServiceRunning + } + + function Assert-PendingUpgrade($expected) { + $exists = Test-Path "$installDir\PendingUpgrade" + if ($exists -ne $expected) { + throw "PendingUpgrade directory: expected=$expected, actual=$exists" + } + } + + # ============================================= + # Test scenarios + # ============================================= + + switch ("${{ matrix.scenario }}") { + + "staging-upgrade" { + Write-Host "=== Scenario: Staging upgrade e2e ===" + # Install LKG, mount, staging upgrade, unmount, verify completion + Install-GVFS $lkgInstaller + Assert-ServiceRunning + $mountPid = Mount-TestRepo + + Install-GVFS $newInstaller @("/STAGEIFMOUNTED=true") + Assert-MountAlive $mountPid + Assert-PendingUpgrade $true + + Unmount-TestRepo + Restart-Service + Assert-PendingUpgrade $false + Write-Host "PASS: Staging upgrade completed" + } + + "clean-upgrade" { + Write-Host "=== Scenario: Clean upgrade (traditional) ===" + Install-GVFS $lkgInstaller + Assert-ServiceRunning + Mount-TestRepo | Write-Host + + Install-GVFS $newInstaller @("/STAGEIFMOUNTED=false") + Assert-PendingUpgrade $false + Assert-ServiceRunning + Write-Host "PASS: Clean upgrade completed" + } + + "double-staging" { + Write-Host "=== Scenario: Double staging install ===" + # Install LKG, mount, staging install twice, verify second overwrites + Install-GVFS $lkgInstaller + Assert-ServiceRunning + $mountPid = Mount-TestRepo + + Install-GVFS $newInstaller @("/STAGEIFMOUNTED=true") + Assert-MountAlive $mountPid + Assert-PendingUpgrade $true + + # Second staging install should overwrite PendingUpgrade + Install-GVFS $newInstaller @("/STAGEIFMOUNTED=true") + Assert-MountAlive $mountPid + Assert-PendingUpgrade $true + + Unmount-TestRepo + Restart-Service + Assert-PendingUpgrade $false + Write-Host "PASS: Double staging handled correctly" + } + + "staging-then-clean" { + Write-Host "=== Scenario: Staging then clean install ===" + # Install LKG, mount, staging install, unmount, clean install + # Verify PendingUpgrade is cleaned up by clean install + Install-GVFS $lkgInstaller + Assert-ServiceRunning + $mountPid = Mount-TestRepo + + Install-GVFS $newInstaller @("/STAGEIFMOUNTED=true") + Assert-MountAlive $mountPid + Assert-PendingUpgrade $true + + Unmount-TestRepo + # Now clean install — should remove PendingUpgrade + Install-GVFS $newInstaller @("/STAGEIFMOUNTED=false") + Assert-PendingUpgrade $false + Assert-ServiceRunning + Write-Host "PASS: Staging then clean install handled correctly" + } + + "mount-safety-deferral" { + Write-Host "=== Scenario: Mount safety deferral ===" + # Install LKG, mount, staging install, restart service WITH mount + # running — upgrade should be deferred + Install-GVFS $lkgInstaller + Assert-ServiceRunning + $mountPid = Mount-TestRepo + + Install-GVFS $newInstaller @("/STAGEIFMOUNTED=true") + Assert-MountAlive $mountPid + Assert-PendingUpgrade $true + + # Restart service WITHOUT unmounting — upgrade should defer + Restart-Service + Assert-MountAlive $mountPid + Assert-PendingUpgrade $true + Write-Host "Upgrade correctly deferred while mount running" + + # Now unmount and restart — should complete + Unmount-TestRepo + Restart-Service + Assert-PendingUpgrade $false + Write-Host "PASS: Mount safety deferral works correctly" + } + + default { + throw "Unknown scenario: ${{ matrix.scenario }}" + } + } + + - name: Upload service logs + if: always() && steps.skip.outputs.result != 'true' + uses: actions/upload-artifact@v7 + continue-on-error: true + with: + name: UpgradeTest_Logs_${{ matrix.scenario }} + path: | + C:\ProgramData\GVFS\GVFS.Service\Logs\ + C:\temp\gvfs-install-logs\ diff --git a/Directory.Build.props b/Directory.Build.props index 76c51bce4..16a5a8c68 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -22,8 +22,13 @@ + net10.0-windows10.0.17763.0 + latest win-x64 x64 + true + true + Speed $(ProjectOutPath)bin\ $(ProjectOutPath)obj\ diff --git a/Directory.Build.targets b/Directory.Build.targets index 578902043..272ea11ec 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -1,17 +1,48 @@ - - - - - $(GVFSVersion) - - - false - - - - - + + + + $(GVFSVersion) + + false + + + + + + + + + <_ManagedOutFragment>bin\$(Configuration)\$(TargetFramework)\win-x64 + + + + <_NativeHook Include="$(RepoOutPath)GitHooksLoader\bin\x64\$(Configuration)\GitHooksLoader.exe" /> + <_NativeHook Include="$(RepoOutPath)GVFS.ReadObjectHook\bin\x64\$(Configuration)\GVFS.ReadObjectHook.exe" /> + <_NativeHook Include="$(RepoOutPath)GVFS.PostIndexChangedHook\bin\x64\$(Configuration)\GVFS.PostIndexChangedHook.exe" /> + <_NativeHook Include="$(RepoOutPath)GVFS.VirtualFileSystemHook\bin\x64\$(Configuration)\GVFS.VirtualFileSystemHook.exe" /> + + <_PeerExe Include="$(RepoOutPath)GVFS.Mount\$(_ManagedOutFragment)\GVFS.Mount.exe" /> + <_PeerExe Include="$(RepoOutPath)GVFS.Mount\$(_ManagedOutFragment)\GVFS.Mount.dll" /> + <_PeerExe Include="$(RepoOutPath)GVFS.Mount\$(_ManagedOutFragment)\GVFS.Mount.runtimeconfig.json" /> + <_PeerExe Include="$(RepoOutPath)GVFS.Mount\$(_ManagedOutFragment)\GVFS.Mount.deps.json" /> + <_PeerExe Include="$(RepoOutPath)GVFS.Hooks\$(_ManagedOutFragment)\GVFS.Hooks.exe" /> + <_PeerExe Include="$(RepoOutPath)GVFS.Hooks\$(_ManagedOutFragment)\GVFS.Hooks.dll" /> + <_PeerExe Include="$(RepoOutPath)GVFS.Hooks\$(_ManagedOutFragment)\GVFS.Hooks.runtimeconfig.json" /> + <_PeerExe Include="$(RepoOutPath)GVFS.Hooks\$(_ManagedOutFragment)\GVFS.Hooks.deps.json" /> + <_PeerExe Include="$(RepoOutPath)GVFS.Service\$(_ManagedOutFragment)\GVFS.Service.exe" /> + <_PeerExe Include="$(RepoOutPath)GVFS.Service\$(_ManagedOutFragment)\GVFS.Service.dll" /> + <_PeerExe Include="$(RepoOutPath)GVFS.Service\$(_ManagedOutFragment)\GVFS.Service.runtimeconfig.json" /> + <_PeerExe Include="$(RepoOutPath)GVFS.Service\$(_ManagedOutFragment)\GVFS.Service.deps.json" /> + + + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 000000000..faf9cf3ae --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,42 @@ + + + + true + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/GVFS.sln b/GVFS.sln index 80a2cbf0e..0bc5735c3 100644 --- a/GVFS.sln +++ b/GVFS.sln @@ -13,8 +13,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GVFS.FunctionalTests", "GVF EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GVFS.FunctionalTests.LockHolder", "GVFS\GVFS.FunctionalTests.LockHolder\GVFS.FunctionalTests.LockHolder.csproj", "{B26985C3-250A-4805-AA97-AD0604331AC7}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GVFS.GVFlt", "GVFS\GVFS.GVFlt\GVFS.GVFlt.csproj", "{B366D3B6-1E85-4015-8DB0-D5FA4331ECE4}" -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GVFS.Hooks", "GVFS\GVFS.Hooks\GVFS.Hooks.csproj", "{EDB4A40E-CFC9-486A-BDC5-AB2951FD8EDC}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GVFS.Mount", "GVFS\GVFS.Mount\GVFS.Mount.csproj", "{F96089C2-6D09-4349-B65D-9CCA6160C6A5}" @@ -33,8 +31,6 @@ Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "GVFS.ReadObjectHook", "GVFS EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GVFS.Service", "GVFS\GVFS.Service\GVFS.Service.csproj", "{5E236AF3-31D7-4313-A129-F080FF058283}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GVFS.Service.UI", "GVFS\GVFS.Service.UI\GVFS.Service.UI.csproj", "{D8FB16E2-EAE0-4E05-A993-940062CD7CA7}" -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GVFS.Tests", "GVFS\GVFS.Tests\GVFS.Tests.csproj", "{FE70E0D6-B0A6-421D-AA12-F28F822F09A0}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GVFS.UnitTests", "GVFS\GVFS.UnitTests\GVFS.UnitTests.csproj", "{1A46C414-7F39-4EF0-B216-A88033D18678}" @@ -49,6 +45,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GVFS.Payload", "GVFS\GVFS.P EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GVFS.Installers", "GVFS\GVFS.Installers\GVFS.Installers.csproj", "{258FEAC0-5E2D-408A-9652-9E9653219F3B}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GVFS.CommandLine.Tests", "GVFS\GVFS.CommandLine.Tests\GVFS.CommandLine.Tests.csproj", "{4D201963-957A-436A-8E43-79A63FB84B94}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|x64 = Debug|x64 @@ -75,10 +73,6 @@ Global {B26985C3-250A-4805-AA97-AD0604331AC7}.Debug|x64.Build.0 = Debug|Any CPU {B26985C3-250A-4805-AA97-AD0604331AC7}.Release|x64.ActiveCfg = Release|Any CPU {B26985C3-250A-4805-AA97-AD0604331AC7}.Release|x64.Build.0 = Release|Any CPU - {B366D3B6-1E85-4015-8DB0-D5FA4331ECE4}.Debug|x64.ActiveCfg = Debug|Any CPU - {B366D3B6-1E85-4015-8DB0-D5FA4331ECE4}.Debug|x64.Build.0 = Debug|Any CPU - {B366D3B6-1E85-4015-8DB0-D5FA4331ECE4}.Release|x64.ActiveCfg = Release|Any CPU - {B366D3B6-1E85-4015-8DB0-D5FA4331ECE4}.Release|x64.Build.0 = Release|Any CPU {EDB4A40E-CFC9-486A-BDC5-AB2951FD8EDC}.Debug|x64.ActiveCfg = Debug|Any CPU {EDB4A40E-CFC9-486A-BDC5-AB2951FD8EDC}.Debug|x64.Build.0 = Debug|Any CPU {EDB4A40E-CFC9-486A-BDC5-AB2951FD8EDC}.Release|x64.ActiveCfg = Release|Any CPU @@ -115,10 +109,6 @@ Global {5E236AF3-31D7-4313-A129-F080FF058283}.Debug|x64.Build.0 = Debug|Any CPU {5E236AF3-31D7-4313-A129-F080FF058283}.Release|x64.ActiveCfg = Release|Any CPU {5E236AF3-31D7-4313-A129-F080FF058283}.Release|x64.Build.0 = Release|Any CPU - {D8FB16E2-EAE0-4E05-A993-940062CD7CA7}.Debug|x64.ActiveCfg = Debug|Any CPU - {D8FB16E2-EAE0-4E05-A993-940062CD7CA7}.Debug|x64.Build.0 = Debug|Any CPU - {D8FB16E2-EAE0-4E05-A993-940062CD7CA7}.Release|x64.ActiveCfg = Release|Any CPU - {D8FB16E2-EAE0-4E05-A993-940062CD7CA7}.Release|x64.Build.0 = Release|Any CPU {FE70E0D6-B0A6-421D-AA12-F28F822F09A0}.Debug|x64.ActiveCfg = Debug|Any CPU {FE70E0D6-B0A6-421D-AA12-F28F822F09A0}.Debug|x64.Build.0 = Debug|Any CPU {FE70E0D6-B0A6-421D-AA12-F28F822F09A0}.Release|x64.ActiveCfg = Release|Any CPU @@ -147,6 +137,10 @@ Global {258FEAC0-5E2D-408A-9652-9E9653219F3B}.Debug|x64.Build.0 = Debug|Any CPU {258FEAC0-5E2D-408A-9652-9E9653219F3B}.Release|x64.ActiveCfg = Release|Any CPU {258FEAC0-5E2D-408A-9652-9E9653219F3B}.Release|x64.Build.0 = Release|Any CPU + {4D201963-957A-436A-8E43-79A63FB84B94}.Debug|x64.ActiveCfg = Debug|Any CPU + {4D201963-957A-436A-8E43-79A63FB84B94}.Debug|x64.Build.0 = Debug|Any CPU + {4D201963-957A-436A-8E43-79A63FB84B94}.Release|x64.ActiveCfg = Release|Any CPU + {4D201963-957A-436A-8E43-79A63FB84B94}.Release|x64.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/GVFS/FastFetch/FastFetch.csproj b/GVFS/FastFetch/FastFetch.csproj index df25afb5b..ad8ec89a3 100644 --- a/GVFS/FastFetch/FastFetch.csproj +++ b/GVFS/FastFetch/FastFetch.csproj @@ -2,7 +2,6 @@ Exe - net471 x64 true @@ -12,15 +11,14 @@ - - + + Microsoft400 @@ -29,3 +27,4 @@ + diff --git a/GVFS/FastFetch/FastFetchVerb.cs b/GVFS/FastFetch/FastFetchVerb.cs index f08f735e7..27c5b1c4f 100644 --- a/GVFS/FastFetch/FastFetchVerb.cs +++ b/GVFS/FastFetch/FastFetchVerb.cs @@ -1,14 +1,13 @@ -using CommandLine; using GVFS.Common; using GVFS.Common.Git; using GVFS.Common.Http; using GVFS.Common.Prefetch; using GVFS.Common.Tracing; using System; +using System.CommandLine; namespace FastFetch { - [Verb("fastfetch", HelpText = "Fast-fetch a branch")] public class FastFetchVerb { // Testing has shown that more than 16 download threads does not improve @@ -19,131 +18,149 @@ public class FastFetchVerb private const int ExitFailure = 1; private const int ExitSuccess = 0; - [Option( - 'c', - "commit", - Required = false, - HelpText = "Commit to fetch")] public string Commit { get; set; } - [Option( - 'b', - "branch", - Required = false, - HelpText = "Branch to fetch")] public string Branch { get; set; } - [Option( - "cache-server-url", - Required = false, - Default = "", - HelpText = "Defines the url of the cache server")] public string CacheServerUrl { get; set; } - [Option( - "chunk-size", - Required = false, - Default = 4000, - HelpText = "Sets the number of objects to be downloaded in a single pack")] public int ChunkSize { get; set; } - [Option( - "checkout", - Required = false, - Default = false, - HelpText = "Checkout the target commit into the working directory after fetching")] public bool Checkout { get; set; } - [Option( - "force-checkout", - Required = false, - Default = false, - HelpText = "Force FastFetch to checkout content as if the current repo had just been initialized." + - "This allows you to include more folders from the repo that were not originally checked out." + - "Can only be used with the --checkout option.")] public bool ForceCheckout { get; set; } - [Option( - "search-thread-count", - Required = false, - Default = 0, - HelpText = "Sets the number of threads to use for finding missing blobs. (0 for number of logical cores)")] public int SearchThreadCount { get; set; } - [Option( - "download-thread-count", - Required = false, - Default = 0, - HelpText = "Sets the number of threads to use for downloading. (0 for number of logical cores)")] public int DownloadThreadCount { get; set; } - [Option( - "index-thread-count", - Required = false, - Default = 0, - HelpText = "Sets the number of threads to use for indexing. (0 for number of logical cores)")] public int IndexThreadCount { get; set; } - [Option( - "checkout-thread-count", - Required = false, - Default = 0, - HelpText = "Sets the number of threads to use for checkout. (0 for number of logical cores)")] public int CheckoutThreadCount { get; set; } - [Option( - 'r', - "max-retries", - Required = false, - Default = 10, - HelpText = "Sets the maximum number of attempts for downloading a pack")] - public int MaxAttempts { get; set; } - [Option( - "git-path", - Default = "", - Required = false, - HelpText = "Sets the path and filename for git.exe if it isn't expected to be on %PATH%.")] public string GitBinPath { get; set; } - - [Option( - "folders", - Required = false, - Default = "", - HelpText = "A semicolon-delimited list of folders to fetch")] + public string FolderList { get; set; } - [Option( - "folders-list", - Required = false, - Default = "", - HelpText = "A file containing line-delimited list of folders to fetch")] public string FolderListFile { get; set; } - [Option( - "Allow-index-metadata-update-from-working-tree", - Required = false, - Default = false, - HelpText = "When specified, index metadata (file times and sizes) is updated from disk if not already in the index. " + - "This flag should only be used when the working tree is known to be in a good state. " + - "Do not use this flag if the working tree is not 100% known to be good as it would cause 'git status' to misreport.")] public bool AllowIndexMetadataUpdateFromWorkingTree { get; set; } - [Option( - "verbose", - Required = false, - Default = false, - HelpText = "Show all outputs on the console in addition to writing them to a log file")] public bool Verbose { get; set; } - [Option( - "parent-activity-id", - Required = false, - Default = "", - HelpText = "The GUID of the caller - used for telemetry purposes.")] public string ParentActivityId { get; set; } + public static RootCommand BuildRootCommand() + { + RootCommand rootCommand = new RootCommand("Fast-fetch a branch"); + + Option commitOption = new Option("--commit", new[] { "-c" }) { Description = "Commit to fetch" }; + rootCommand.Add(commitOption); + + Option branchOption = new Option("--branch", new[] { "-b" }) { Description = "Branch to fetch" }; + rootCommand.Add(branchOption); + + Option cacheServerUrlOption = new Option("--cache-server-url") + { + Description = "Defines the url of the cache server", + DefaultValueFactory = (_) => "" + }; + rootCommand.Add(cacheServerUrlOption); + + Option chunkSizeOption = new Option("--chunk-size") + { + Description = "Sets the number of objects to be downloaded in a single pack", + DefaultValueFactory = (_) => 4000 + }; + rootCommand.Add(chunkSizeOption); + + Option checkoutOption = new Option("--checkout") { Description = "Checkout the target commit into the working directory after fetching" }; + rootCommand.Add(checkoutOption); + + Option forceCheckoutOption = new Option("--force-checkout") { Description = "Force FastFetch to checkout content as if the current repo had just been initialized." }; + rootCommand.Add(forceCheckoutOption); + + Option searchThreadCountOption = new Option("--search-thread-count") { Description = "Sets the number of threads to use for finding missing blobs. (0 for number of logical cores)", DefaultValueFactory = (_) => 0 }; + rootCommand.Add(searchThreadCountOption); + + Option downloadThreadCountOption = new Option("--download-thread-count") { Description = "Sets the number of threads to use for downloading. (0 for number of logical cores)", DefaultValueFactory = (_) => 0 }; + rootCommand.Add(downloadThreadCountOption); + + Option indexThreadCountOption = new Option("--index-thread-count") { Description = "Sets the number of threads to use for indexing. (0 for number of logical cores)", DefaultValueFactory = (_) => 0 }; + rootCommand.Add(indexThreadCountOption); + + Option checkoutThreadCountOption = new Option("--checkout-thread-count") { Description = "Sets the number of threads to use for checkout. (0 for number of logical cores)", DefaultValueFactory = (_) => 0 }; + rootCommand.Add(checkoutThreadCountOption); + + Option maxRetriesOption = new Option("--max-retries", new[] { "-r" }) + { + Description = "Sets the maximum number of attempts for downloading a pack", + DefaultValueFactory = (_) => 10 + }; + rootCommand.Add(maxRetriesOption); + + Option gitPathOption = new Option("--git-path") + { + Description = "Sets the path and filename for git.exe if it isn't expected to be on %PATH%.", + DefaultValueFactory = (_) => "" + }; + rootCommand.Add(gitPathOption); + + Option foldersOption = new Option("--folders") + { + Description = "A semicolon-delimited list of folders to fetch", + DefaultValueFactory = (_) => "" + }; + rootCommand.Add(foldersOption); + + Option foldersListOption = new Option("--folders-list") + { + Description = "A file containing line-delimited list of folders to fetch", + DefaultValueFactory = (_) => "" + }; + rootCommand.Add(foldersListOption); + + Option allowIndexMetadataOption = new Option("--allow-index-metadata-update-from-working-tree") { Description = "When specified, index metadata is updated from disk if not already in the index." }; + rootCommand.Add(allowIndexMetadataOption); + + Option verboseOption = new Option("--verbose") { Description = "Show all outputs on the console in addition to writing them to a log file" }; + rootCommand.Add(verboseOption); + + Option parentActivityIdOption = new Option("--parent-activity-id") + { + Description = "The GUID of the caller - used for telemetry purposes.", + DefaultValueFactory = (_) => "" + }; + rootCommand.Add(parentActivityIdOption); + + rootCommand.SetAction((ParseResult result) => + { + FastFetchVerb verb = new FastFetchVerb(); + verb.Commit = result.GetValue(commitOption); + verb.Branch = result.GetValue(branchOption); + verb.CacheServerUrl = result.GetValue(cacheServerUrlOption) ?? ""; + verb.ChunkSize = result.GetValue(chunkSizeOption); + verb.Checkout = result.GetValue(checkoutOption); + verb.ForceCheckout = result.GetValue(forceCheckoutOption); + verb.SearchThreadCount = result.GetValue(searchThreadCountOption); + verb.DownloadThreadCount = result.GetValue(downloadThreadCountOption); + verb.IndexThreadCount = result.GetValue(indexThreadCountOption); + verb.CheckoutThreadCount = result.GetValue(checkoutThreadCountOption); + verb.MaxAttempts = result.GetValue(maxRetriesOption); + verb.GitBinPath = result.GetValue(gitPathOption) ?? ""; + verb.FolderList = result.GetValue(foldersOption) ?? ""; + verb.FolderListFile = result.GetValue(foldersListOption) ?? ""; + verb.AllowIndexMetadataUpdateFromWorkingTree = result.GetValue(allowIndexMetadataOption); + verb.Verbose = result.GetValue(verboseOption); + verb.ParentActivityId = result.GetValue(parentActivityIdOption) ?? ""; + verb.Execute(); + }); + + return rootCommand; + } + public void Execute() { Environment.ExitCode = this.ExecuteWithExitCode(); diff --git a/GVFS/FastFetch/InternalsVisibleTo.cs b/GVFS/FastFetch/InternalsVisibleTo.cs new file mode 100644 index 000000000..200018c1f --- /dev/null +++ b/GVFS/FastFetch/InternalsVisibleTo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("GVFS.CommandLine.Tests")] diff --git a/GVFS/FastFetch/Program.cs b/GVFS/FastFetch/Program.cs index 03d94d1ec..b82ad97a4 100644 --- a/GVFS/FastFetch/Program.cs +++ b/GVFS/FastFetch/Program.cs @@ -1,6 +1,9 @@ -using CommandLine; +using System.CommandLine; +using System.Runtime.CompilerServices; using GVFS.PlatformLoader; +[assembly: InternalsVisibleTo("GVFS.CommandLine.Tests")] + namespace FastFetch { public class Program @@ -8,8 +11,10 @@ public class Program public static void Main(string[] args) { GVFSPlatformLoader.Initialize(); - Parser.Default.ParseArguments(args) - .WithParsed(fastFetch => fastFetch.Execute()); + RootCommand rootCommand = BuildRootCommand(); + rootCommand.Parse(args).Invoke(); } + + internal static RootCommand BuildRootCommand() => FastFetchVerb.BuildRootCommand(); } } diff --git a/GVFS/GVFS.CommandLine.Tests/FastFetchCliTests.cs b/GVFS/GVFS.CommandLine.Tests/FastFetchCliTests.cs new file mode 100644 index 000000000..67a811f22 --- /dev/null +++ b/GVFS/GVFS.CommandLine.Tests/FastFetchCliTests.cs @@ -0,0 +1,237 @@ +using System.CommandLine; +using System.CommandLine.Parsing; +using System.Linq; +using NUnit.Framework; + +namespace GVFS.CommandLine.Tests +{ + /// + /// Tests that FastFetch CLI parsing matches the original CommandLineParser behavior. + /// Verifies short aliases, defaults, and option names are backward-compatible. + /// + [TestFixture] + public class FastFetchCliTests + { + private RootCommand rootCommand; + + [SetUp] + public void SetUp() + { + rootCommand = FastFetch.Program.BuildRootCommand(); + } + + #region Short Aliases + + [Test] + public void CommitOption_HasShortAlias_C() + { + var opt = FindOption("--commit"); + Assert.That(opt, Is.Not.Null, "Expected --commit option to exist"); + Assert.That(opt.Aliases, Does.Contain("-c"), "Expected -c short alias for --commit"); + } + + [Test] + public void BranchOption_HasShortAlias_B() + { + var opt = FindOption("--branch"); + Assert.That(opt, Is.Not.Null, "Expected --branch option to exist"); + Assert.That(opt.Aliases, Does.Contain("-b"), "Expected -b short alias for --branch"); + } + + [Test] + public void MaxRetriesOption_HasShortAlias_R() + { + var opt = FindOption("--max-retries"); + Assert.That(opt, Is.Not.Null, "Expected --max-retries option to exist"); + Assert.That(opt.Aliases, Does.Contain("-r"), "Expected -r short alias for --max-retries"); + } + + [TestCase("-c", "abc123")] + [TestCase("-b", "main")] + [TestCase("-r", "5")] + public void ShortAliases_ParseCorrectly(string alias, string value) + { + var parseResult = rootCommand.Parse(new[] { alias, value }); + Assert.That(parseResult.Errors, Is.Empty, $"Parsing '{alias} {value}' should produce no errors"); + } + + #endregion + + #region Default Values + + [Test] + public void ChunkSize_DefaultsTo4000() + { + var parseResult = rootCommand.Parse(System.Array.Empty()); + var opt = FindOption("--chunk-size"); + Assert.That(opt, Is.Not.Null); + Assert.That(parseResult.GetValue(opt), Is.EqualTo(4000), + "ChunkSize should default to 4000 when not specified"); + } + + [Test] + public void MaxRetries_DefaultsTo10() + { + var parseResult = rootCommand.Parse(System.Array.Empty()); + var opt = FindOption("--max-retries"); + Assert.That(opt, Is.Not.Null); + Assert.That(parseResult.GetValue(opt), Is.EqualTo(10), + "MaxRetries should default to 10 when not specified"); + } + + [Test] + public void Folders_DefaultsToEmptyString() + { + var parseResult = rootCommand.Parse(System.Array.Empty()); + var opt = FindOption("--folders"); + Assert.That(opt, Is.Not.Null); + Assert.That(parseResult.GetValue(opt), Is.EqualTo(""), + "Folders should default to empty string when not specified"); + } + + [Test] + public void FoldersList_DefaultsToEmptyString() + { + var parseResult = rootCommand.Parse(System.Array.Empty()); + var opt = FindOption("--folders-list"); + Assert.That(opt, Is.Not.Null); + Assert.That(parseResult.GetValue(opt), Is.EqualTo(""), + "FoldersList should default to empty string when not specified"); + } + + [Test] + public void BooleanOptions_DefaultToFalse() + { + var parseResult = rootCommand.Parse(System.Array.Empty()); + + var checkout = FindOption("--checkout"); + var forceCheckout = FindOption("--force-checkout"); + var verbose = FindOption("--verbose"); + var allowIndexMetadata = FindOption("--allow-index-metadata-update-from-working-tree"); + + Assert.Multiple(() => + { + Assert.That(parseResult.GetValue(checkout), Is.False, "--checkout should default to false"); + Assert.That(parseResult.GetValue(forceCheckout), Is.False, "--force-checkout should default to false"); + Assert.That(parseResult.GetValue(verbose), Is.False, "--verbose should default to false"); + Assert.That(parseResult.GetValue(allowIndexMetadata), Is.False, "--allow-index-metadata-update-from-working-tree should default to false"); + }); + } + + [Test] + public void IntThreadOptions_DefaultToZero() + { + var parseResult = rootCommand.Parse(System.Array.Empty()); + + var search = FindOption("--search-thread-count"); + var download = FindOption("--download-thread-count"); + var index = FindOption("--index-thread-count"); + var checkoutThread = FindOption("--checkout-thread-count"); + + Assert.Multiple(() => + { + Assert.That(parseResult.GetValue(search), Is.EqualTo(0)); + Assert.That(parseResult.GetValue(download), Is.EqualTo(0)); + Assert.That(parseResult.GetValue(index), Is.EqualTo(0)); + Assert.That(parseResult.GetValue(checkoutThread), Is.EqualTo(0)); + }); + } + + #endregion + + #region Explicit Value Parsing + + [Test] + public void ChunkSize_ExplicitValue_Overrides() + { + var parseResult = rootCommand.Parse(new[] { "--chunk-size", "8000" }); + var opt = FindOption("--chunk-size"); + Assert.That(parseResult.GetValue(opt), Is.EqualTo(8000)); + } + + [Test] + public void MaxRetries_ExplicitValue_Overrides() + { + var parseResult = rootCommand.Parse(new[] { "--max-retries", "3" }); + var opt = FindOption("--max-retries"); + Assert.That(parseResult.GetValue(opt), Is.EqualTo(3)); + } + + [Test] + public void CommitAndBranch_ParseWithShortAliases() + { + var parseResult = rootCommand.Parse(new[] { "-c", "abc123", "-b", "feature/test" }); + var commitOpt = FindOption("--commit"); + var branchOpt = FindOption("--branch"); + Assert.Multiple(() => + { + Assert.That(parseResult.GetValue(commitOpt), Is.EqualTo("abc123")); + Assert.That(parseResult.GetValue(branchOpt), Is.EqualTo("feature/test")); + }); + } + + [Test] + public void AllStringOptions_ParseCorrectly() + { + var parseResult = rootCommand.Parse(new[] + { + "--commit", "abc123", + "--branch", "main", + "--cache-server-url", "https://cache.example.com", + "--git-path", @"C:\Program Files\Git\bin\git.exe", + "--folders", "src;lib", + "--folders-list", @"C:\folders.txt", + "--parent-activity-id", "12345678-1234-1234-1234-123456789012" + }); + + Assert.That(parseResult.Errors, Is.Empty, "All string options should parse without errors"); + } + + [Test] + public void MaxRetries_ShortAlias_R_ParsesCorrectly() + { + var parseResult = rootCommand.Parse(new[] { "-r", "5" }); + var opt = FindOption("--max-retries"); + Assert.That(parseResult.GetValue(opt), Is.EqualTo(5)); + } + + #endregion + + #region All Expected Options Exist + + [Test] + public void AllExpectedOptions_Exist() + { + var expectedOptions = new[] + { + "--commit", "--branch", "--cache-server-url", "--chunk-size", + "--checkout", "--force-checkout", "--search-thread-count", + "--download-thread-count", "--index-thread-count", "--checkout-thread-count", + "--max-retries", "--git-path", "--folders", "--folders-list", + "--allow-index-metadata-update-from-working-tree", "--verbose", + "--parent-activity-id" + }; + + foreach (var optName in expectedOptions) + { + Assert.That(FindOption(optName), Is.Not.Null, $"Expected option {optName} to exist"); + } + } + + #endregion + + #region Helpers + + private Option FindOption(string name) + { + return rootCommand.Options.FirstOrDefault(o => o.Name == name || o.Aliases.Contains(name)); + } + + private Option FindOption(string name) + { + return rootCommand.Options.FirstOrDefault(o => o.Name == name || o.Aliases.Contains(name)) as Option; + } + + #endregion + } +} diff --git a/GVFS/GVFS.CommandLine.Tests/GVFS.CommandLine.Tests.csproj b/GVFS/GVFS.CommandLine.Tests/GVFS.CommandLine.Tests.csproj new file mode 100644 index 000000000..356206fb8 --- /dev/null +++ b/GVFS/GVFS.CommandLine.Tests/GVFS.CommandLine.Tests.csproj @@ -0,0 +1,22 @@ + + + + true + Exe + false + false + + + + + + + + + + + + + + + diff --git a/GVFS/GVFS.CommandLine.Tests/GvfsMainCliTests.cs b/GVFS/GVFS.CommandLine.Tests/GvfsMainCliTests.cs new file mode 100644 index 000000000..4eb360808 --- /dev/null +++ b/GVFS/GVFS.CommandLine.Tests/GvfsMainCliTests.cs @@ -0,0 +1,427 @@ +using System.CommandLine; +using System.CommandLine.Parsing; +using System.Linq; +using NUnit.Framework; + +namespace GVFS.CommandLine.Tests +{ + /// + /// Tests that GVFS main CLI parsing matches the original CommandLineParser behavior. + /// Verifies all verb subcommands, short aliases, and option compatibility. + /// + /// + /// System.CommandLine 2.0.3 note: Option.Name holds the primary name (e.g. "--list"), + /// while Option.Aliases only contains SHORT aliases added via Aliases.Add() (e.g. "-l"). + /// All lookups must check both Name and Aliases to find an option by any of its names. + /// + [TestFixture] + public class GvfsMainCliTests + { + private RootCommand rootCommand; + + [SetUp] + public void SetUp() + { + rootCommand = GVFS.Program.BuildRootCommand(); + } + + #region All Subcommands Exist + + [TestCase("cache-server")] + [TestCase("clone")] + [TestCase("config")] + [TestCase("dehydrate")] + [TestCase("diagnose")] + [TestCase("health")] + [TestCase("log")] + [TestCase("mount")] + [TestCase("prefetch")] + [TestCase("repair")] + [TestCase("service")] + [TestCase("sparse")] + [TestCase("status")] + [TestCase("unmount")] + [TestCase("upgrade")] + [TestCase("version")] + public void Subcommand_Exists(string name) + { + var cmd = rootCommand.Subcommands.FirstOrDefault(c => c.Name == name); + Assert.That(cmd, Is.Not.Null, $"Expected subcommand '{name}' to exist"); + } + + #endregion + + #region Clone Short Aliases + + [Test] + public void Clone_BranchOption_HasShortAlias_B() + { + var opt = FindOptionOnCommand("clone", "--branch"); + Assert.That(opt, Is.Not.Null, "Expected --branch option on clone"); + Assert.That(opt.Aliases, Does.Contain("-b"), "Expected -b short alias for clone --branch"); + } + + [Test] + public void Clone_ParsesWithShortAlias() + { + var parseResult = rootCommand.Parse(new[] { "clone", "https://example.com/repo", "-b", "main" }); + Assert.That(parseResult.Errors, Is.Empty, "clone with -b should parse without errors"); + } + + #endregion + + #region Config Short Aliases + + [Test] + public void Config_ListOption_HasShortAlias_L() + { + var opt = FindOptionOnCommand("config", "--list"); + Assert.That(opt, Is.Not.Null, "Expected --list option on config"); + Assert.That(opt.Aliases, Does.Contain("-l"), "Expected -l short alias for config --list"); + } + + [Test] + public void Config_DeleteOption_HasShortAlias_D() + { + var opt = FindOptionOnCommand("config", "--delete"); + Assert.That(opt, Is.Not.Null, "Expected --delete option on config"); + Assert.That(opt.Aliases, Does.Contain("-d"), "Expected -d short alias for config --delete"); + } + + #endregion + + #region Health Short Aliases + + [Test] + public void Health_DisplayCountOption_HasName_N() + { + var opt = FindOptionOnCommand("health", "-n"); + Assert.That(opt, Is.Not.Null, "Expected -n option on health command"); + } + + [Test] + public void Health_DirectoryOption_HasShortAlias_D() + { + var opt = FindOptionOnCommand("health", "--directory"); + Assert.That(opt, Is.Not.Null, "Expected --directory option on health"); + Assert.That(opt.Aliases, Does.Contain("-d"), "Expected -d short alias for health --directory"); + } + + [Test] + public void Health_StatusOption_HasShortAlias_S() + { + var opt = FindOptionOnCommand("health", "--status"); + Assert.That(opt, Is.Not.Null, "Expected --status option on health"); + Assert.That(opt.Aliases, Does.Contain("-s"), "Expected -s short alias for health --status"); + } + + #endregion + + #region Mount Short Aliases + + [Test] + public void Mount_VerbosityOption_HasShortAlias_V() + { + var opt = FindOptionOnCommand("mount", "--verbosity"); + Assert.That(opt, Is.Not.Null, "Expected --verbosity option on mount"); + Assert.That(opt.Aliases, Does.Contain("-v"), "Expected -v short alias for mount --verbosity"); + } + + [Test] + public void Mount_KeywordsOption_HasShortAlias_K() + { + var opt = FindOptionOnCommand("mount", "--keywords"); + Assert.That(opt, Is.Not.Null, "Expected --keywords option on mount"); + Assert.That(opt.Aliases, Does.Contain("-k"), "Expected -k short alias for mount --keywords"); + } + + #endregion + + #region Prefetch Short Aliases + + [Test] + public void Prefetch_CommitsOption_HasShortAlias_C() + { + var opt = FindOptionOnCommand("prefetch", "--commits"); + Assert.That(opt, Is.Not.Null, "Expected --commits option on prefetch"); + Assert.That(opt.Aliases, Does.Contain("-c"), "Expected -c short alias for prefetch --commits"); + } + + #endregion + + #region Sparse Short Aliases (7 aliases) + + [TestCase("--set", "-s")] + [TestCase("--file", "-f")] + [TestCase("--add", "-a")] + [TestCase("--remove", "-r")] + [TestCase("--list", "-l")] + [TestCase("--prune", "-p")] + [TestCase("--disable", "-d")] + public void Sparse_Option_HasShortAlias(string longName, string shortAlias) + { + var opt = FindOptionOnCommand("sparse", longName); + Assert.That(opt, Is.Not.Null, $"Expected {longName} option on sparse"); + Assert.That(opt.Aliases, Does.Contain(shortAlias), + $"Expected {shortAlias} short alias for sparse {longName}"); + } + + #endregion + + #region String Defaults (null-coalesce guards) + + [Test] + public void Dehydrate_Folders_DefaultsToNullOrEmpty() + { + // Original had Default = "". Now we guard with ?? "" in the action. + // From parse result, the default for unset string is null. + // The null-coalesce guard ensures the verb receives "" not null. + var opt = FindOptionOnCommand("dehydrate", "--folders"); + Assert.That(opt, Is.Not.Null, "Expected --folders option on dehydrate"); + } + + [Test] + public void Prefetch_StringOptions_Exist() + { + var expectedOptions = new[] { "--files", "--folders", "--folders-list", "--files-list" }; + + foreach (var optName in expectedOptions) + { + var opt = FindOptionOnCommand("prefetch", optName); + Assert.That(opt, Is.Not.Null, $"Expected {optName} option on prefetch"); + } + } + + [Test] + public void Sparse_StringOptions_Exist() + { + var expectedOptions = new[] { "--set", "--file", "--add", "--remove" }; + + foreach (var optName in expectedOptions) + { + var opt = FindOptionOnCommand("sparse", optName); + Assert.That(opt, Is.Not.Null, $"Expected {optName} option on sparse"); + } + } + + #endregion + + #region Full Command Parsing + + [Test] + public void Clone_FullCommandLine_ParsesCorrectly() + { + var parseResult = rootCommand.Parse(new[] + { + "clone", "https://example.com/repo", @"C:\Users\test\repo", + "--cache-server-url", "https://cache.test", + "-b", "develop", + "--single-branch", + "--no-mount", + "--no-prefetch" + }); + Assert.That(parseResult.Errors, Is.Empty, "Full clone command should parse without errors"); + } + + [Test] + public void Mount_FullCommandLine_ParsesCorrectly() + { + var parseResult = rootCommand.Parse(new[] + { + "mount", @"C:\Users\test\repo", + "-v", "Warning", + "-k", "Network" + }); + Assert.That(parseResult.Errors, Is.Empty, "Full mount command should parse without errors"); + } + + [Test] + public void Prefetch_FullCommandLine_ParsesCorrectly() + { + var parseResult = rootCommand.Parse(new[] + { + "prefetch", + "--folders", "src;lib", + "--files", "*.cs;*.h", + "-c", + "--verbose" + }); + Assert.That(parseResult.Errors, Is.Empty, "Full prefetch command should parse without errors"); + } + + [Test] + public void Sparse_FullCommandLine_ParsesCorrectly() + { + var parseResult = rootCommand.Parse(new[] + { + "sparse", + "-s", "src;lib;tests", + "-l" + }); + Assert.That(parseResult.Errors, Is.Empty, "Full sparse command should parse without errors"); + } + + [Test] + public void Health_FullCommandLine_ParsesCorrectly() + { + var parseResult = rootCommand.Parse(new[] + { + "health", + "-n", "20", + "-d", @"src\components", + "-s" + }); + Assert.That(parseResult.Errors, Is.Empty, "Full health command should parse without errors"); + } + + [Test] + public void Dehydrate_FullCommandLine_ParsesCorrectly() + { + var parseResult = rootCommand.Parse(new[] + { + "dehydrate", + "--confirm", + "--folders", "src/old;temp" + }); + Assert.That(parseResult.Errors, Is.Empty, "Full dehydrate command with --confirm --folders should parse without errors"); + } + + [Test] + public void Service_FullCommandLine_ParsesCorrectly() + { + var parseResult = rootCommand.Parse(new[] { "service", "--list-mounted" }); + Assert.That(parseResult.Errors, Is.Empty); + } + + [Test] + public void Upgrade_FullCommandLine_ParsesCorrectly() + { + var parseResult = rootCommand.Parse(new[] { "upgrade", "--confirm" }); + Assert.That(parseResult.Errors, Is.Empty); + } + + [Test] + public void Unmount_FullCommandLine_ParsesCorrectly() + { + var parseResult = rootCommand.Parse(new[] { "unmount" }); + Assert.That(parseResult.Errors, Is.Empty); + } + + [Test] + public void Config_FullCommandLine_List_ParsesCorrectly() + { + var parseResult = rootCommand.Parse(new[] { "config", "-l" }); + Assert.That(parseResult.Errors, Is.Empty, "config -l should parse without errors"); + } + + [Test] + public void Config_FullCommandLine_SetKeyValue_ParsesCorrectly() + { + var parseResult = rootCommand.Parse(new[] { "config", "mykey", "myvalue" }); + Assert.That(parseResult.Errors, Is.Empty, "config key value should parse without errors"); + } + + [Test] + public void Config_FullCommandLine_Delete_ParsesCorrectly() + { + var parseResult = rootCommand.Parse(new[] { "config", "-d", "mykey" }); + Assert.That(parseResult.Errors, Is.Empty, "config -d key should parse without errors"); + } + + [Test] + public void Repair_FullCommandLine_ParsesCorrectly() + { + var parseResult = rootCommand.Parse(new[] { "repair", "--confirm" }); + Assert.That(parseResult.Errors, Is.Empty); + } + + #endregion + + #region Option Existence per Verb (complete verification) + + [Test] + public void Clone_HasAllExpectedOptions() + { + var expected = new[] { "--cache-server-url", "--branch", "--single-branch", "--no-mount", "--no-prefetch", "--local-cache-path" }; + foreach (var optName in expected) + { + Assert.That(FindOptionOnCommand("clone", optName), Is.Not.Null, + $"clone should have {optName} option"); + } + } + + [Test] + public void Dehydrate_HasAllExpectedOptions() + { + var expected = new[] { "--confirm", "--no-status", "--folders" }; + foreach (var optName in expected) + { + Assert.That(FindOptionOnCommand("dehydrate", optName), Is.Not.Null, + $"dehydrate should have {optName} option"); + } + } + + [Test] + public void Prefetch_HasAllExpectedOptions() + { + var expected = new[] { "--files", "--folders", "--folders-list", "--stdin-files-list", + "--stdin-folders-list", "--files-list", "--hydrate", "--commits", "--verbose" }; + foreach (var optName in expected) + { + Assert.That(FindOptionOnCommand("prefetch", optName), Is.Not.Null, + $"prefetch should have {optName} option"); + } + } + + [Test] + public void Service_HasAllExpectedOptions() + { + var expected = new[] { "--mount-all", "--unmount-all", "--list-mounted" }; + foreach (var optName in expected) + { + Assert.That(FindOptionOnCommand("service", optName), Is.Not.Null, + $"service should have {optName} option"); + } + } + + [Test] + public void Upgrade_HasAllExpectedOptions() + { + var expected = new[] { "--confirm", "--dry-run", "--no-verify" }; + foreach (var optName in expected) + { + Assert.That(FindOptionOnCommand("upgrade", optName), Is.Not.Null, + $"upgrade should have {optName} option"); + } + } + + [Test] + public void Unmount_HasSkipLockOption() + { + Assert.That(FindOptionOnCommand("unmount", "--skip-wait-for-lock"), Is.Not.Null, + "unmount should have --skip-wait-for-lock option"); + } + + #endregion + + #region Helpers + + private Command FindSubcommand(string name) + { + return rootCommand.Subcommands.FirstOrDefault(c => c.Name == name) + ?? throw new System.Exception($"Subcommand '{name}' not found"); + } + + /// + /// Find an option on a subcommand by checking both Name and Aliases. + /// System.CommandLine 2.0.3: Name holds the primary name, Aliases holds only short aliases. + /// + private Option FindOptionOnCommand(string subcommandName, string optionName) + { + var cmd = FindSubcommand(subcommandName); + return cmd.Options.FirstOrDefault(o => o.Name == optionName || o.Aliases.Contains(optionName)); + } + + #endregion + } +} diff --git a/GVFS/GVFS.CommandLine.Tests/GvfsMountCliTests.cs b/GVFS/GVFS.CommandLine.Tests/GvfsMountCliTests.cs new file mode 100644 index 000000000..c352b6429 --- /dev/null +++ b/GVFS/GVFS.CommandLine.Tests/GvfsMountCliTests.cs @@ -0,0 +1,235 @@ +using System.CommandLine; +using System.CommandLine.Parsing; +using System.Linq; +using NUnit.Framework; + +namespace GVFS.CommandLine.Tests +{ + /// + /// Tests that GVFS.Mount CLI parsing matches the original CommandLineParser behavior. + /// Verifies defaults (not aliases — this is an internal tool called with long names). + /// + [TestFixture] + public class GvfsMountCliTests + { + private RootCommand rootCommand; + + [SetUp] + public void SetUp() + { + rootCommand = GVFS.Mount.Program.BuildRootCommand(); + } + + #region Default Values — Critical (these were previously broken) + + [Test] + public void Verbosity_DefaultsToInformational() + { + var parseResult = rootCommand.Parse(new[] { "C:\\repo" }); + var opt = FindOption("--verbosity"); + Assert.That(opt, Is.Not.Null); + Assert.That(parseResult.GetValue(opt), Is.EqualTo("Informational"), + "Verbosity should default to 'Informational' when not specified"); + } + + [Test] + public void Keywords_DefaultsToAny() + { + var parseResult = rootCommand.Parse(new[] { "C:\\repo" }); + var opt = FindOption("--keywords"); + Assert.That(opt, Is.Not.Null); + Assert.That(parseResult.GetValue(opt), Is.EqualTo("Any"), + "Keywords should default to 'Any' when not specified"); + } + + [Test] + public void StartedByService_DefaultsToFalse() + { + var parseResult = rootCommand.Parse(new[] { "C:\\repo" }); + var opt = FindOption("--StartedByService"); + Assert.That(opt, Is.Not.Null); + Assert.That(parseResult.GetValue(opt), Is.EqualTo("false"), + "StartedByService should default to 'false' when not specified"); + } + + #endregion + + #region Defaults Are Not Aliases + + [Test] + public void Informational_IsNotAnAlias() + { + var opt = FindOption("--verbosity"); + Assert.That(opt, Is.Not.Null); + Assert.That(opt.Aliases, Does.Not.Contain("Informational"), + "'Informational' should NOT be an alias for --verbosity"); + } + + [Test] + public void Any_IsNotAnAlias() + { + var opt = FindOption("--keywords"); + Assert.That(opt, Is.Not.Null); + Assert.That(opt.Aliases, Does.Not.Contain("Any"), + "'Any' should NOT be an alias for --keywords"); + } + + [Test] + public void False_IsNotAnAlias() + { + var opt = FindOption("--StartedByService"); + Assert.That(opt, Is.Not.Null); + Assert.That(opt.Aliases, Does.Not.Contain("false"), + "'false' should NOT be an alias for --StartedByService"); + } + + #endregion + + #region Explicit Value Parsing + + [Test] + public void Verbosity_ExplicitValue_Overrides() + { + var parseResult = rootCommand.Parse(new[] { "C:\\repo", "--verbosity", "Verbose" }); + var opt = FindOption("--verbosity"); + Assert.That(parseResult.GetValue(opt), Is.EqualTo("Verbose")); + } + + [Test] + public void Keywords_ExplicitValue_Overrides() + { + var parseResult = rootCommand.Parse(new[] { "C:\\repo", "--keywords", "Network" }); + var opt = FindOption("--keywords"); + Assert.That(parseResult.GetValue(opt), Is.EqualTo("Network")); + } + + [Test] + public void StartedByService_ExplicitValue_Overrides() + { + var parseResult = rootCommand.Parse(new[] { "C:\\repo", "--StartedByService", "true" }); + var opt = FindOption("--StartedByService"); + Assert.That(parseResult.GetValue(opt), Is.EqualTo("true")); + } + + [Test] + public void DebugWindow_DefaultsFalse() + { + var parseResult = rootCommand.Parse(new[] { "C:\\repo" }); + var opt = FindOption("--debug-window"); + Assert.That(parseResult.GetValue(opt), Is.False); + } + + [Test] + public void StartedByVerb_DefaultsFalse() + { + var parseResult = rootCommand.Parse(new[] { "C:\\repo" }); + var opt = FindOption("--StartedByVerb"); + Assert.That(parseResult.GetValue(opt), Is.False); + } + + #endregion + + #region Argument Parsing + + [Test] + public void EnlistmentRootPath_IsParsed() + { + var parseResult = rootCommand.Parse(new[] { @"C:\Users\test\repo" }); + var arg = rootCommand.Arguments.FirstOrDefault(a => a.Name == "enlistment-root-path"); + Assert.That(arg, Is.Not.Null); + Assert.That(parseResult.GetValue((Argument)arg), Is.EqualTo(@"C:\Users\test\repo")); + } + + #endregion + + #region Full Command Line (matches how MountVerb launches GVFS.Mount.exe) + + [Test] + public void MountVerbCommandLine_ParsesCorrectly() + { + // MountVerb constructs: GVFS.Mount --verbosity Informational --keywords Any --StartedByVerb + var parseResult = rootCommand.Parse(new[] + { + @"C:\Users\test\repo", + "--verbosity", "Informational", + "--keywords", "Any", + "--StartedByVerb" + }); + + Assert.That(parseResult.Errors, Is.Empty, "MountVerb-style command line should parse without errors"); + + var verbOpt = FindOption("--verbosity"); + var kwOpt = FindOption("--keywords"); + var verbStartedOpt = FindOption("--StartedByVerb"); + + Assert.Multiple(() => + { + Assert.That(parseResult.GetValue(verbOpt), Is.EqualTo("Informational")); + Assert.That(parseResult.GetValue(kwOpt), Is.EqualTo("Any")); + Assert.That(parseResult.GetValue(verbStartedOpt), Is.True); + }); + } + + [Test] + public void ServiceStartedCommandLine_ParsesCorrectly() + { + // MountVerb constructs when started by service: + // GVFS.Mount --verbosity Warning --keywords Network --StartedByService true + var parseResult = rootCommand.Parse(new[] + { + @"C:\Users\test\repo", + "--verbosity", "Warning", + "--keywords", "Network", + "--StartedByService", "true" + }); + + Assert.That(parseResult.Errors, Is.Empty); + + var verbOpt = FindOption("--verbosity"); + var kwOpt = FindOption("--keywords"); + var svcOpt = FindOption("--StartedByService"); + + Assert.Multiple(() => + { + Assert.That(parseResult.GetValue(verbOpt), Is.EqualTo("Warning")); + Assert.That(parseResult.GetValue(kwOpt), Is.EqualTo("Network")); + Assert.That(parseResult.GetValue(svcOpt), Is.EqualTo("true")); + }); + } + + #endregion + + #region All Expected Options Exist + + [Test] + public void AllExpectedOptions_Exist() + { + var expectedOptions = new[] + { + "--verbosity", "--keywords", "--debug-window", + "--StartedByService", "--StartedByVerb" + }; + + foreach (var optName in expectedOptions) + { + Assert.That(FindOption(optName), Is.Not.Null, $"Expected option {optName} to exist"); + } + } + + #endregion + + #region Helpers + + private Option FindOption(string name) + { + return rootCommand.Options.FirstOrDefault(o => o.Name == name || o.Aliases.Contains(name)); + } + + private Option FindOption(string name) + { + return rootCommand.Options.FirstOrDefault(o => o.Name == name || o.Aliases.Contains(name)) as Option; + } + + #endregion + } +} diff --git a/GVFS/GVFS.CommandLine.Tests/Program.cs b/GVFS/GVFS.CommandLine.Tests/Program.cs new file mode 100644 index 000000000..d30bfea11 --- /dev/null +++ b/GVFS/GVFS.CommandLine.Tests/Program.cs @@ -0,0 +1,9 @@ +using NUnitLite; + +namespace GVFS.CommandLine.Tests +{ + public class Program + { + public static int Main(string[] args) => new AutoRun().Execute(args); + } +} diff --git a/GVFS/GVFS.Common/FileBasedDictionary.cs b/GVFS/GVFS.Common/FileBasedDictionary.cs index ae601e9d9..ce680f517 100644 --- a/GVFS/GVFS.Common/FileBasedDictionary.cs +++ b/GVFS/GVFS.Common/FileBasedDictionary.cs @@ -1,9 +1,9 @@ -using GVFS.Common.FileSystem; +using GVFS.Common.FileSystem; using GVFS.Common.Tracing; -using Newtonsoft.Json; using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Text.Json; namespace GVFS.Common { @@ -120,7 +120,7 @@ private bool TryParseAddLine(string line, out TKey key, out TValue value, out st { try { - KeyValuePair kvp = JsonConvert.DeserializeObject>(line); + KeyValuePair kvp = GVFSJsonOptions.Deserialize>(line); key = kvp.Key; value = kvp.Value; } @@ -140,7 +140,7 @@ private bool TryParseRemoveLine(string line, out TKey key, out string error) { try { - key = JsonConvert.DeserializeObject(line); + key = GVFSJsonOptions.Deserialize(line); } catch (JsonException ex) { @@ -162,7 +162,7 @@ private IEnumerable GenerateDataLines() { foreach (KeyValuePair kvp in this.data) { - yield return this.FormatAddLine(JsonConvert.SerializeObject(kvp).Trim()); + yield return this.FormatAddLine(GVFSJsonOptions.Serialize(kvp).Trim()); } } } diff --git a/GVFS/GVFS.Common/FileSystem/HooksInstaller.cs b/GVFS/GVFS.Common/FileSystem/HooksInstaller.cs index 76fc6d028..7ebd28eec 100644 --- a/GVFS/GVFS.Common/FileSystem/HooksInstaller.cs +++ b/GVFS/GVFS.Common/FileSystem/HooksInstaller.cs @@ -22,7 +22,11 @@ public static class HooksInstaller static HooksInstaller() { - ExecutingDirectory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + // Environment.ProcessPath can be null in NativeAOT or certain hosting scenarios. + string processPath = Environment.ProcessPath; + ExecutingDirectory = !string.IsNullOrEmpty(processPath) + ? Path.GetDirectoryName(processPath) + : AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar); } public static string MergeHooksData(string[] defaultHooksLines, string filename, string hookName) diff --git a/GVFS/GVFS.Common/GVFS.Common.csproj b/GVFS/GVFS.Common/GVFS.Common.csproj index daacd2ca2..4005b2eca 100644 --- a/GVFS/GVFS.Common/GVFS.Common.csproj +++ b/GVFS/GVFS.Common/GVFS.Common.csproj @@ -1,20 +1,13 @@ - + - net471 true - - - - - - - - - + + + @@ -28,3 +21,4 @@ + diff --git a/GVFS/GVFS.Common/GVFSConstants.cs b/GVFS/GVFS.Common/GVFSConstants.cs index 24374b26a..e81ecc635 100644 --- a/GVFS/GVFS.Common/GVFSConstants.cs +++ b/GVFS/GVFS.Common/GVFSConstants.cs @@ -68,7 +68,6 @@ public static class Service { public const string ServiceName = "GVFS.Service"; public const string LogDirectory = "Logs"; - public const string UIName = "GVFS.Service.UI"; } public static class MediaTypes @@ -108,7 +107,6 @@ public static class LogFileTypes public const string Prefetch = "prefetch"; public const string Repair = "repair"; public const string Service = "service"; - public const string ServiceUI = "service_ui"; public const string Sparse = "sparse"; public const string UpgradeVerb = UpgradePrefix + "_verb"; public const string UpgradeProcess = UpgradePrefix + "_process"; diff --git a/GVFS/GVFS.Common/GVFSEnlistment.cs b/GVFS/GVFS.Common/GVFSEnlistment.cs index eb407c175..e0b18b28b 100644 --- a/GVFS/GVFS.Common/GVFSEnlistment.cs +++ b/GVFS/GVFS.Common/GVFSEnlistment.cs @@ -2,9 +2,9 @@ using GVFS.Common.Git; using GVFS.Common.NamedPipes; using GVFS.Common.Tracing; -using Newtonsoft.Json; using System; using System.IO; +using System.Text.Json; using System.Threading; namespace GVFS.Common @@ -261,7 +261,7 @@ public static bool WaitUntilMounted(ITracer tracer, string pipeName, string enli else { tracer.RelatedInfo($"{nameof(WaitUntilMounted)}: Waiting 500ms for mount process to be ready"); - Thread.Sleep(500); + Thread.Sleep(100); } } catch (BrokenPipeException e) @@ -270,7 +270,7 @@ public static bool WaitUntilMounted(ITracer tracer, string pipeName, string enli tracer.RelatedError($"{nameof(WaitUntilMounted)}: {errorMessage}"); return false; } - catch (JsonReaderException e) + catch (JsonException e) { errorMessage = string.Format("Failed to parse response from GVFS.Mount.\n {0}", e); tracer.RelatedError($"{nameof(WaitUntilMounted)}: {errorMessage}"); diff --git a/GVFS/GVFS.Common/GVFSJsonContext.cs b/GVFS/GVFS.Common/GVFSJsonContext.cs new file mode 100644 index 000000000..1a203ee8a --- /dev/null +++ b/GVFS/GVFS.Common/GVFSJsonContext.cs @@ -0,0 +1,47 @@ +using GVFS.Common.Http; +using GVFS.Common.NamedPipes; +using GVFS.Common.Tracing; +using GVFS.Common.Prefetch; +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace GVFS.Common +{ + /// + /// Source-generated JSON serializer context for all types used in GVFS serialization. + /// This enables trim-safe and AOT-compatible JSON serialization without reflection. + /// + [JsonSourceGenerationOptions( + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = new[] { typeof(VersionConverter) })] + [JsonSerializable(typeof(string))] + [JsonSerializable(typeof(Dictionary))] + [JsonSerializable(typeof(KeyValuePair))] + [JsonSerializable(typeof(List))] + [JsonSerializable(typeof(List))] + [JsonSerializable(typeof(ServerGVFSConfig))] + [JsonSerializable(typeof(VersionResponse))] + [JsonSerializable(typeof(InternalVerbParameters))] + [JsonSerializable(typeof(CacheServerInfo))] + [JsonSerializable(typeof(NamedPipeMessages.GetStatus.Response), TypeInfoPropertyName = "GetStatusResponse")] + [JsonSerializable(typeof(NamedPipeMessages.DehydrateFolders.Request), TypeInfoPropertyName = "DehydrateFoldersRequest")] + [JsonSerializable(typeof(NamedPipeMessages.DehydrateFolders.Response), TypeInfoPropertyName = "DehydrateFoldersResponse")] + [JsonSerializable(typeof(NamedPipeMessages.Notification.Request), TypeInfoPropertyName = "NotificationRequest")] + [JsonSerializable(typeof(NamedPipeMessages.UnregisterRepoRequest))] + [JsonSerializable(typeof(NamedPipeMessages.UnregisterRepoRequest.Response), TypeInfoPropertyName = "UnregisterRepoResponse")] + [JsonSerializable(typeof(NamedPipeMessages.RegisterRepoRequest))] + [JsonSerializable(typeof(NamedPipeMessages.RegisterRepoRequest.Response), TypeInfoPropertyName = "RegisterRepoResponse")] + [JsonSerializable(typeof(NamedPipeMessages.EnableAndAttachProjFSRequest))] + [JsonSerializable(typeof(NamedPipeMessages.EnableAndAttachProjFSRequest.Response), TypeInfoPropertyName = "EnableAndAttachProjFSResponse")] + [JsonSerializable(typeof(NamedPipeMessages.GetActiveRepoListRequest))] + [JsonSerializable(typeof(NamedPipeMessages.GetActiveRepoListRequest.Response), TypeInfoPropertyName = "GetActiveRepoListResponse")] + [JsonSerializable(typeof(NamedPipeMessages.BaseResponse))] + [JsonSerializable(typeof(TelemetryDaemonEventListener.PipeMessage))] + [JsonSerializable(typeof(PrettyConsoleEventListener.ConsoleOutputPayload))] + internal partial class GVFSJsonContext : JsonSerializerContext + { + } +} diff --git a/GVFS/GVFS.Common/GVFSJsonOptions.cs b/GVFS/GVFS.Common/GVFSJsonOptions.cs new file mode 100644 index 000000000..b6f98d0bc --- /dev/null +++ b/GVFS/GVFS.Common/GVFSJsonOptions.cs @@ -0,0 +1,58 @@ +using GVFS.Common.Tracing; +using System; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace GVFS.Common +{ + /// + /// Shared JsonSerializerOptions for the GVFS codebase. + /// Uses source-generated GVFSJsonContext for known types (trim-safe/AOT-safe) + /// with DefaultJsonTypeInfoResolver as fallback for types not in the context + /// (e.g., boxed primitives in EventMetadata Dictionary<string, object>). + /// EventMetadata uses a custom converter that handles Dictionary<string, object> + /// without reflection, making it NativeAOT compatible. + /// + public static class GVFSJsonOptions + { + [UnconditionalSuppressMessage("AOT", "IL2026", + Justification = "Uses source-gen context for known types; EventMetadataConverter handles Dictionary without reflection. DefaultJsonTypeInfoResolver fallback handles boxed primitives in EventMetadata.")] + [UnconditionalSuppressMessage("AOT", "IL3050", + Justification = "Uses source-gen context for known types; EventMetadataConverter handles Dictionary without reflection. DefaultJsonTypeInfoResolver fallback handles boxed primitives in EventMetadata.")] + public static readonly JsonSerializerOptions Default = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + Converters = { new VersionConverter(), new EventMetadataConverter() }, + TypeInfoResolverChain = { GVFSJsonContext.Default, new DefaultJsonTypeInfoResolver() }, + }; + + [UnconditionalSuppressMessage("AOT", "IL2026", + Justification = "TypeInfoResolverChain includes GVFSJsonContext (source-gen) + DefaultJsonTypeInfoResolver fallback.")] + [UnconditionalSuppressMessage("AOT", "IL3050", + Justification = "TypeInfoResolverChain includes GVFSJsonContext (source-gen) + DefaultJsonTypeInfoResolver fallback.")] + public static string Serialize(T value) + { + return JsonSerializer.Serialize(value, Default); + } + + [UnconditionalSuppressMessage("AOT", "IL2026", + Justification = "TypeInfoResolverChain includes GVFSJsonContext (source-gen) + DefaultJsonTypeInfoResolver fallback.")] + [UnconditionalSuppressMessage("AOT", "IL3050", + Justification = "TypeInfoResolverChain includes GVFSJsonContext (source-gen) + DefaultJsonTypeInfoResolver fallback.")] + public static string Serialize(object value, Type inputType) + { + return JsonSerializer.Serialize(value, inputType, Default); + } + + [UnconditionalSuppressMessage("AOT", "IL2026", + Justification = "TypeInfoResolverChain includes GVFSJsonContext (source-gen) + DefaultJsonTypeInfoResolver fallback.")] + [UnconditionalSuppressMessage("AOT", "IL3050", + Justification = "TypeInfoResolverChain includes GVFSJsonContext (source-gen) + DefaultJsonTypeInfoResolver fallback.")] + public static T Deserialize(string json) + { + return JsonSerializer.Deserialize(json, Default); + } + } +} diff --git a/GVFS/GVFS.Common/Git/GitAuthentication.cs b/GVFS/GVFS.Common/Git/GitAuthentication.cs index bb81a86c1..fff37ad9a 100644 --- a/GVFS/GVFS.Common/Git/GitAuthentication.cs +++ b/GVFS/GVFS.Common/Git/GitAuthentication.cs @@ -309,6 +309,23 @@ public void ConfigureHttpClientHandlerSslIfNeeded(ITracer tracer, HttpClientHand } } + public void ConfigureSocketsHandlerSslIfNeeded(ITracer tracer, SocketsHttpHandler socketsHandler, GitProcess gitProcess) + { + X509Certificate2 cert = this.GitSsl?.GetCertificate(tracer, gitProcess); + if (cert != null) + { + System.Net.Security.SslClientAuthenticationOptions sslOptions = new System.Net.Security.SslClientAuthenticationOptions(); + + if (this.GitSsl != null && !this.GitSsl.ShouldVerify) + { + sslOptions.RemoteCertificateValidationCallback = (sender, certificate, chain, errors) => true; // CodeQL [SM02184] TLS verification can be disabled by Git itself, so this is just mirroring a feature already exposed. + } + + sslOptions.ClientCertificates = new System.Security.Cryptography.X509Certificates.X509CertificateCollection { cert }; + socketsHandler.SslOptions = sslOptions; + } + } + private static bool TryParseCredentialString(string credentialString, out string username, out string password) { if (credentialString != null) diff --git a/GVFS/GVFS.Common/Git/GitRepo.cs b/GVFS/GVFS.Common/Git/GitRepo.cs index b2b3ad7b3..e5aefa579 100644 --- a/GVFS/GVFS.Common/Git/GitRepo.cs +++ b/GVFS/GVFS.Common/Git/GitRepo.cs @@ -191,7 +191,21 @@ private LooseBlobState GetLooseBlobStateAtPath(string blobPath, Action wr return state; } + + /// + /// A read-only stream wrapper that counts the total bytes read. + /// Used to detect truncated loose objects where DeflateStream returns + /// fewer bytes than the header declares (see GetLooseBlobStateAtPath). + /// + private sealed class CountingStream : Stream + { + private readonly Stream inner; + private long bytesRead; + + public CountingStream(Stream inner) + { + this.inner = inner; + } + + public long BytesRead => this.bytesRead; + + public override bool CanRead => this.inner.CanRead; + public override bool CanSeek => this.inner.CanSeek; + public override bool CanWrite => this.inner.CanWrite; + public override long Length => this.inner.Length; + public override long Position + { + get => this.inner.Position; + set => this.inner.Position = value; + } + + public override int Read(byte[] buffer, int offset, int count) + { + int read = this.inner.Read(buffer, offset, count); + this.bytesRead += read; + return read; + } + + public override int ReadByte() + { + int b = this.inner.ReadByte(); + if (b >= 0) + { + this.bytesRead++; + } + + return b; + } + + public override void Flush() => this.inner.Flush(); + public override long Seek(long offset, SeekOrigin origin) => this.inner.Seek(offset, origin); + public override void SetLength(long value) => this.inner.SetLength(value); + public override void Write(byte[] buffer, int offset, int count) => this.inner.Write(buffer, offset, count); + } } } diff --git a/GVFS/GVFS.Common/Git/GitSsl.cs b/GVFS/GVFS.Common/Git/GitSsl.cs index 58be50d5e..e2c61a17f 100644 --- a/GVFS/GVFS.Common/Git/GitSsl.cs +++ b/GVFS/GVFS.Common/Git/GitSsl.cs @@ -161,7 +161,7 @@ private X509Certificate2 GetCertificateFromFile(ITracer tracer, EventMetadata me try { byte[] certificateContent = this.fileSystem.ReadAllBytes(this.certificatePathOrSubjectCommonName); - X509Certificate2 cert = new X509Certificate2(certificateContent, certificatePassword); + X509Certificate2 cert = X509CertificateLoader.LoadPkcs12(certificateContent, certificatePassword); if (this.ShouldVerify && cert != null && !this.certificateVerifier.Verify(cert)) { tracer.RelatedWarning(metadata, "Certficate was found, but is invalid."); diff --git a/GVFS/GVFS.Common/Http/CacheServerInfo.cs b/GVFS/GVFS.Common/Http/CacheServerInfo.cs index 4fe1e58c6..0ec929b0d 100644 --- a/GVFS/GVFS.Common/Http/CacheServerInfo.cs +++ b/GVFS/GVFS.Common/Http/CacheServerInfo.cs @@ -1,5 +1,5 @@ -using Newtonsoft.Json; -using System; +using System; +using System.Text.Json.Serialization; namespace GVFS.Common.Http { diff --git a/GVFS/GVFS.Common/Http/ConfigHttpRequestor.cs b/GVFS/GVFS.Common/Http/ConfigHttpRequestor.cs index 444e8dbd5..95b531bb3 100644 --- a/GVFS/GVFS.Common/Http/ConfigHttpRequestor.cs +++ b/GVFS/GVFS.Common/Http/ConfigHttpRequestor.cs @@ -1,8 +1,8 @@ -using GVFS.Common.Tracing; -using Newtonsoft.Json; +using GVFS.Common.Tracing; using System; using System.Net; using System.Net.Http; +using System.Text.Json; using System.Threading; namespace GVFS.Common.Http @@ -66,10 +66,10 @@ public bool TryQueryGVFSConfig(bool logErrors, out ServerGVFSConfig serverGVFSCo try { string configString = response.RetryableReadToEnd(); - ServerGVFSConfig config = JsonConvert.DeserializeObject(configString); + ServerGVFSConfig config = GVFSJsonOptions.Deserialize(configString); return new RetryWrapper.CallbackResult(config); } - catch (JsonReaderException e) + catch (JsonException e) { return new RetryWrapper.CallbackResult(e, shouldRetry: false); } diff --git a/GVFS/GVFS.Common/Http/GitObjectsHttpRequestor.cs b/GVFS/GVFS.Common/Http/GitObjectsHttpRequestor.cs index 0d11d3cc4..2cdffcb8d 100644 --- a/GVFS/GVFS.Common/Http/GitObjectsHttpRequestor.cs +++ b/GVFS/GVFS.Common/Http/GitObjectsHttpRequestor.cs @@ -1,8 +1,8 @@ -using GVFS.Common.Git; +using GVFS.Common.Git; using GVFS.Common.Tracing; -using Newtonsoft.Json; using System; using System.Collections.Generic; +using System.Text.Json.Serialization; using System.Linq; using System.Net; using System.Net.Http; @@ -81,7 +81,7 @@ public virtual List QueryForFileSizes(IEnumerable objectI } string objectSizesString = response.RetryableReadToEnd(); - List objectSizes = JsonConvert.DeserializeObject>(objectSizesString); + List objectSizes = GVFSJsonOptions.Deserialize>(objectSizesString); return new RetryWrapper>.CallbackResult(objectSizes); } }); @@ -343,8 +343,8 @@ private string ObjectIdsJsonGenerator(long requestId, Func> public class GitObjectSize { - public readonly string Id; - public readonly long Size; + public string Id { get; set; } + public long Size { get; set; } [JsonConstructor] public GitObjectSize(string id, long size) diff --git a/GVFS/GVFS.Common/Http/HttpRequestor.cs b/GVFS/GVFS.Common/Http/HttpRequestor.cs index 1f05d6aab..0f9767dde 100644 --- a/GVFS/GVFS.Common/Http/HttpRequestor.cs +++ b/GVFS/GVFS.Common/Http/HttpRequestor.cs @@ -8,7 +8,6 @@ using System.Net; using System.Net.Http; using System.Net.Http.Headers; -using System.Runtime.InteropServices; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -32,19 +31,10 @@ public abstract class HttpRequestor : IDisposable static HttpRequestor() { - /* If machine.config is locked, then initializing ServicePointManager will fail and be unrecoverable. - * Machine.config locking is typically very brief (~1ms by the antivirus scanner) so we can attempt to lock - * it ourselves (by opening it for read) *beforehand and briefly wait if it's locked */ - using (var machineConfigLock = GetMachineConfigLock()) - { - ServicePointManager.SecurityProtocol = ServicePointManager.SecurityProtocol | SecurityProtocolType.Tls12; - - // HTTP downloads are I/O-bound, not CPU-bound, so we default to - // 2x ProcessorCount. Can be overridden via gvfs.max-http-connections. - int connectionLimit = 2 * Environment.ProcessorCount; - ServicePointManager.DefaultConnectionLimit = connectionLimit; - availableConnections = new SemaphoreSlim(connectionLimit); - } + // HTTP downloads are I/O-bound, not CPU-bound, so we default to + // 2x ProcessorCount. Can be overridden via gvfs.max-http-connections. + int connectionLimit = 2 * Environment.ProcessorCount; + availableConnections = new SemaphoreSlim(connectionLimit); } protected HttpRequestor(ITracer tracer, RetryConfig retryConfig, Enlistment enlistment) @@ -62,13 +52,29 @@ protected HttpRequestor(ITracer tracer, RetryConfig retryConfig, Enlistment enli TryApplyConnectionLimitFromConfig(tracer, enlistment); } - HttpClientHandler httpClientHandler = new HttpClientHandler() { UseDefaultCredentials = true }; + // WARNING: Do NOT set Credentials or ServerCredentials on this handler. + // + // Setting Credentials = CredentialCache.DefaultCredentials causes the handler + // to perform an NTLM/Negotiate challenge-response on every new connection. + // On SocketsHttpHandler this adds ~400ms per request vs ~14ms without. + // + // GVFS cache servers and Azure DevOps accept PAT/OAuth tokens via the + // "Authorization: Basic " header that SendRequest already attaches. + // Transport-level credentials are redundant and purely wasteful. + SocketsHttpHandler handler = new SocketsHttpHandler() + { + MaxConnectionsPerServer = Environment.ProcessorCount, + PooledConnectionLifetime = Timeout.InfiniteTimeSpan, + PooledConnectionIdleTimeout = TimeSpan.FromMinutes(5), + }; - this.authentication.ConfigureHttpClientHandlerSslIfNeeded(this.Tracer, httpClientHandler, enlistment.CreateGitProcess()); + this.authentication.ConfigureSocketsHandlerSslIfNeeded(this.Tracer, handler, enlistment.CreateGitProcess()); - this.client = new HttpClient(httpClientHandler) + this.client = new HttpClient(handler) { - Timeout = retryConfig.Timeout + Timeout = retryConfig.Timeout, + DefaultRequestVersion = HttpVersion.Version11, + DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact, }; this.userAgentHeader = new ProductInfoHeaderValue(ProcessHelper.GetEntryClassName(), ProcessHelper.GetCurrentProcessVersion()); @@ -180,8 +186,8 @@ protected GitEndPointResponseData SendRequest( } catch (HttpRequestException httpRequestException) when (TryGetResponseMessageFromHttpRequestException(httpRequestException, request, out response)) { - /* HttpClientHandler will automatically resubmit in certain circumstances, such as a 401 unauthorized response when UseDefaultCredentials - * is true but another credential was provided. This resubmit can throw (instead of returning a proper status code) in some case cases, such + /* HttpClientHandler may automatically resubmit in certain circumstances, such as a 401 unauthorized response. + * This resubmit can throw (instead of returning a proper status code) in some cases, such * as when there is an exception loading the default credentials. * If we can extract the original response message from the exception, we can continue and process the original failed status code. */ Tracer.RelatedWarning(responseMetadata, $"An exception occurred while resubmitting the request, but the original response is available."); @@ -391,8 +397,7 @@ private static void TryApplyConnectionLimitFromConfig(ITracer tracer, Enlistment if (configuredLimit > 0) { - int currentLimit = ServicePointManager.DefaultConnectionLimit; - ServicePointManager.DefaultConnectionLimit = configuredLimit; + int currentLimit = availableConnections.CurrentCount; // Adjust the existing semaphore rather than replacing it, so any // in-flight waiters release permits to the correct instance. @@ -425,28 +430,5 @@ private static void TryApplyConnectionLimitFromConfig(ITracer tracer, Enlistment tracer.RelatedWarning(metadata, "HttpRequestor: Failed to read gvfs.max-http-connections config, using default"); } } - - private static FileStream GetMachineConfigLock() - { - var machineConfigLocation = RuntimeEnvironment.SystemConfigurationFile; - var tries = 0; - var maxTries = 3; - while (tries++ < maxTries) - { - try - { - /* Opening with FileShare.Read will fail if another process (eg antivirus) has opened the file for write, - but will still let ServicePointManager read the file.*/ - FileStream stream = File.Open(machineConfigLocation, FileMode.Open, FileAccess.Read, FileShare.Read); - return stream; - } - catch (IOException e) when ((uint)e.HResult == 0x80070020) // SHARING_VIOLATION - { - Thread.Sleep(10); - } - } - /* Couldn't get the lock - the process will likely fail. */ - return null; - } } } diff --git a/GVFS/GVFS.Common/InternalVerbParameters.cs b/GVFS/GVFS.Common/InternalVerbParameters.cs index ec4cd0e3f..b7a09d657 100644 --- a/GVFS/GVFS.Common/InternalVerbParameters.cs +++ b/GVFS/GVFS.Common/InternalVerbParameters.cs @@ -1,4 +1,3 @@ -using Newtonsoft.Json; namespace GVFS.Common { @@ -23,12 +22,12 @@ public InternalVerbParameters( public static InternalVerbParameters FromJson(string json) { - return JsonConvert.DeserializeObject(json); + return GVFSJsonOptions.Deserialize(json); } public string ToJson() { - return JsonConvert.SerializeObject(this); + return GVFSJsonOptions.Serialize(this); } } } diff --git a/GVFS/GVFS.Common/NamedPipes/NamedPipeMessages.cs b/GVFS/GVFS.Common/NamedPipes/NamedPipeMessages.cs index fafb4e7d1..d42c84873 100644 --- a/GVFS/GVFS.Common/NamedPipes/NamedPipeMessages.cs +++ b/GVFS/GVFS.Common/NamedPipes/NamedPipeMessages.cs @@ -1,4 +1,3 @@ -using Newtonsoft.Json; using System; using System.Collections.Generic; @@ -46,12 +45,12 @@ public class Response public static Response FromJson(string json) { - return JsonConvert.DeserializeObject(json); + return GVFSJsonOptions.Deserialize(json); } public string ToJson() { - return JsonConvert.SerializeObject(this); + return GVFSJsonOptions.Serialize(this); } } } @@ -91,8 +90,10 @@ public Response(string result, string data = "") this.Data = data; } - public string Result { get; } - public string Data { get; } + public Response() { } + + public string Result { get; set; } + public string Data { get; set; } public Message CreateMessage() { @@ -130,7 +131,9 @@ public Response(string result) this.Result = result; } - public string Result { get; } + public Response() { } + + public string Result { get; set; } public Message CreateMessage() { @@ -186,7 +189,9 @@ public Response(string result) this.Result = result; } - public string Result { get; } + public Response() { } + + public string Result { get; set; } public Message CreateMessage() { @@ -203,6 +208,10 @@ public static class DehydrateFolders public class Request { + public Request() + { + } + public Request(string backupFolderPath, string folders) { this.Folders = folders; @@ -211,21 +220,27 @@ public Request(string backupFolderPath, string folders) public static Request FromMessage(Message message) { - return JsonConvert.DeserializeObject(message.Body); + return GVFSJsonOptions.Deserialize(message.Body); } - public string Folders { get; } + public string Folders { get; set; } - public string BackupFolderPath { get; } + public string BackupFolderPath { get; set; } public Message CreateMessage() { - return new Message(Dehydrate, JsonConvert.SerializeObject(this)); + return new Message(Dehydrate, GVFSJsonOptions.Serialize(this)); } } public class Response { + public Response() + { + this.SuccessfulFolders = new List(); + this.FailedFolders = new List(); + } + public Response(string result) { this.Result = result; @@ -233,18 +248,18 @@ public Response(string result) this.FailedFolders = new List(); } - public string Result { get; } - public List SuccessfulFolders { get; } - public List FailedFolders { get; } + public string Result { get; set; } + public List SuccessfulFolders { get; set; } + public List FailedFolders { get; set; } public static Response FromMessage(Message message) { - return JsonConvert.DeserializeObject(message.Body); + return GVFSJsonOptions.Deserialize(message.Body); } public Message CreateMessage() { - return new Message(this.Result, JsonConvert.SerializeObject(this)); + return new Message(this.Result, GVFSJsonOptions.Serialize(this)); } } } @@ -259,7 +274,7 @@ public class Request { public Request(List packIndexes) { - this.PackIndexList = JsonConvert.SerializeObject(packIndexes); + this.PackIndexList = GVFSJsonOptions.Serialize(packIndexes); } public Request(Message message) @@ -287,7 +302,9 @@ public Response(string result) this.Result = result; } - public string Result { get; } + public Response() { } + + public string Result { get; set; } public Message CreateMessage() { @@ -324,12 +341,12 @@ public enum Identifier public static Request FromMessage(Message message) { - return JsonConvert.DeserializeObject(message.Body); + return GVFSJsonOptions.Deserialize(message.Body); } public Message ToMessage() { - return new Message(Header, JsonConvert.SerializeObject(this)); + return new Message(Header, GVFSJsonOptions.Serialize(this)); } } } @@ -342,19 +359,19 @@ public class UnregisterRepoRequest public static UnregisterRepoRequest FromMessage(Message message) { - return JsonConvert.DeserializeObject(message.Body); + return GVFSJsonOptions.Deserialize(message.Body); } public Message ToMessage() { - return new Message(Header, JsonConvert.SerializeObject(this)); + return new Message(Header, GVFSJsonOptions.Serialize(this)); } public class Response : BaseResponse { public static Response FromMessage(Message message) { - return JsonConvert.DeserializeObject(message.Body); + return GVFSJsonOptions.Deserialize(message.Body); } } } @@ -368,19 +385,19 @@ public class RegisterRepoRequest public static RegisterRepoRequest FromMessage(Message message) { - return JsonConvert.DeserializeObject(message.Body); + return GVFSJsonOptions.Deserialize(message.Body); } public Message ToMessage() { - return new Message(Header, JsonConvert.SerializeObject(this)); + return new Message(Header, GVFSJsonOptions.Serialize(this)); } public class Response : BaseResponse { public static Response FromMessage(Message message) { - return JsonConvert.DeserializeObject(message.Body); + return GVFSJsonOptions.Deserialize(message.Body); } } } @@ -393,19 +410,19 @@ public class EnableAndAttachProjFSRequest public static EnableAndAttachProjFSRequest FromMessage(Message message) { - return JsonConvert.DeserializeObject(message.Body); + return GVFSJsonOptions.Deserialize(message.Body); } public Message ToMessage() { - return new Message(Header, JsonConvert.SerializeObject(this)); + return new Message(Header, GVFSJsonOptions.Serialize(this)); } public class Response : BaseResponse { public static Response FromMessage(Message message) { - return JsonConvert.DeserializeObject(message.Body); + return GVFSJsonOptions.Deserialize(message.Body); } } } @@ -416,12 +433,12 @@ public class GetActiveRepoListRequest public static GetActiveRepoListRequest FromMessage(Message message) { - return JsonConvert.DeserializeObject(message.Body); + return GVFSJsonOptions.Deserialize(message.Body); } public Message ToMessage() { - return new Message(Header, JsonConvert.SerializeObject(this)); + return new Message(Header, GVFSJsonOptions.Serialize(this)); } public class Response : BaseResponse @@ -430,7 +447,7 @@ public class Response : BaseResponse public static Response FromMessage(Message message) { - return JsonConvert.DeserializeObject(message.Body); + return GVFSJsonOptions.Deserialize(message.Body); } } } @@ -444,7 +461,7 @@ public class BaseResponse public Message ToMessage() { - return new Message(Header, JsonConvert.SerializeObject(this)); + return new Message(Header, GVFSJsonOptions.Serialize(this, this.GetType())); } } } diff --git a/GVFS/GVFS.Common/OrgInfoApiClient.cs b/GVFS/GVFS.Common/OrgInfoApiClient.cs index 9387c66a6..93dbd97d9 100644 --- a/GVFS/GVFS.Common/OrgInfoApiClient.cs +++ b/GVFS/GVFS.Common/OrgInfoApiClient.cs @@ -1,9 +1,9 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net; using System.Net.Http; using System.Text; -using System.Web; namespace GVFS.Common { @@ -69,7 +69,7 @@ private string ConstructRequest(string baseUrl, Dictionary query } isFirst = false; - sb.Append($"{HttpUtility.UrlEncode(kvp.Key)}={HttpUtility.UrlEncode(kvp.Value)}"); + sb.Append($"{WebUtility.UrlEncode(kvp.Key)}={WebUtility.UrlEncode(kvp.Value)}"); } return sb.ToString(); diff --git a/GVFS/GVFS.Common/Prefetch/BlobPrefetcher.cs b/GVFS/GVFS.Common/Prefetch/BlobPrefetcher.cs index cd9b72330..29bc4cc67 100644 --- a/GVFS/GVFS.Common/Prefetch/BlobPrefetcher.cs +++ b/GVFS/GVFS.Common/Prefetch/BlobPrefetcher.cs @@ -1,10 +1,9 @@ -using GVFS.Common.FileSystem; +using GVFS.Common.FileSystem; using GVFS.Common.Git; using GVFS.Common.Http; using GVFS.Common.Prefetch.Git; using GVFS.Common.Prefetch.Pipeline; using GVFS.Common.Tracing; -using Newtonsoft.Json; using System; using System.Collections.Concurrent; using System.Collections.Generic; @@ -140,8 +139,8 @@ public static bool IsNoopPrefetch( lastPrefetchArgs.TryGetValue(PrefetchArgs.Folders, out string lastFoldersString) && lastPrefetchArgs.TryGetValue(PrefetchArgs.Hydrate, out string lastHydrateString)) { - string newFilesString = JsonConvert.SerializeObject(files); - string newFoldersString = JsonConvert.SerializeObject(folders); + string newFilesString = GVFSJsonOptions.Serialize(files); + string newFoldersString = GVFSJsonOptions.Serialize(folders); bool isNoop = commitId == lastCommitId && hydrateFilesAfterDownload.ToString() == lastHydrateString && @@ -587,8 +586,8 @@ private void SavePrefetchArgs(string targetCommit, bool hydrate) new[] { new KeyValuePair(PrefetchArgs.CommitId, targetCommit), - new KeyValuePair(PrefetchArgs.Files, JsonConvert.SerializeObject(this.FileList)), - new KeyValuePair(PrefetchArgs.Folders, JsonConvert.SerializeObject(this.FolderList)), + new KeyValuePair(PrefetchArgs.Files, GVFSJsonOptions.Serialize(this.FileList)), + new KeyValuePair(PrefetchArgs.Folders, GVFSJsonOptions.Serialize(this.FolderList)), new KeyValuePair(PrefetchArgs.Hydrate, hydrate.ToString()), }); } diff --git a/GVFS/GVFS.Common/ProcessHelper.cs b/GVFS/GVFS.Common/ProcessHelper.cs index 3d7e35463..a9731d6d5 100644 --- a/GVFS/GVFS.Common/ProcessHelper.cs +++ b/GVFS/GVFS.Common/ProcessHelper.cs @@ -26,17 +26,28 @@ public static ProcessResult Run(string programName, string args, bool redirectOu public static string GetCurrentProcessLocation() { - Assembly assembly = Assembly.GetExecutingAssembly(); - return Path.GetDirectoryName(assembly.Location); + // Environment.ProcessPath can be null in NativeAOT or certain hosting scenarios. + string processPath = Environment.ProcessPath; + if (!string.IsNullOrEmpty(processPath)) + { + return Path.GetDirectoryName(processPath); + } + + return AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar); } public static string GetEntryClassName() { + // AppDomain.FriendlyName is reliable even when Assembly.GetEntryAssembly() returns null. + string friendlyName = AppDomain.CurrentDomain.FriendlyName; + if (!string.IsNullOrEmpty(friendlyName)) + { + return Path.GetFileNameWithoutExtension(friendlyName); + } + Assembly assembly = Assembly.GetEntryAssembly(); if (assembly == null) { - // The PR build tests doesn't produce an entry assembly because it is run from unmanaged code, - // so we'll fall back on using this assembly. This should never ever happen for a normal exe invocation. assembly = Assembly.GetExecutingAssembly(); } @@ -47,9 +58,16 @@ public static string GetCurrentProcessVersion() { if (currentProcessVersion == null) { - Assembly assembly = Assembly.GetExecutingAssembly(); - FileVersionInfo fileVersionInfo = FileVersionInfo.GetVersionInfo(assembly.Location); - currentProcessVersion = fileVersionInfo.ProductVersion; + string processPath = Environment.ProcessPath; + if (!string.IsNullOrEmpty(processPath)) + { + FileVersionInfo fileVersionInfo = FileVersionInfo.GetVersionInfo(processPath); + currentProcessVersion = fileVersionInfo.ProductVersion; + } + else + { + currentProcessVersion = "0.0.0.0"; + } } return currentProcessVersion; diff --git a/GVFS/GVFS.Common/ServerGVFSConfig.cs b/GVFS/GVFS.Common/ServerGVFSConfig.cs index 1bc9bef36..84f30c119 100644 --- a/GVFS/GVFS.Common/ServerGVFSConfig.cs +++ b/GVFS/GVFS.Common/ServerGVFSConfig.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.Json.Serialization; namespace GVFS.Common { @@ -13,7 +14,10 @@ public class ServerGVFSConfig public class VersionRange { + [JsonConverter(typeof(VersionConverter))] public Version Min { get; set; } + + [JsonConverter(typeof(VersionConverter))] public Version Max { get; set; } } } diff --git a/GVFS/GVFS.Common/Tracing/EventMetadataConverter.cs b/GVFS/GVFS.Common/Tracing/EventMetadataConverter.cs new file mode 100644 index 000000000..5bc7b3927 --- /dev/null +++ b/GVFS/GVFS.Common/Tracing/EventMetadataConverter.cs @@ -0,0 +1,128 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace GVFS.Common.Tracing +{ + /// + /// Custom JSON converter for EventMetadata (Dictionary<string, object>). + /// Handles the known value types stored in EventMetadata without relying on + /// System.Text.Json's polymorphic object serialization, which can produce + /// unexpected results for boxed enums, HttpStatusCode, etc. + /// + public class EventMetadataConverter : JsonConverter + { + public override EventMetadata Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException("Expected StartObject"); + } + + EventMetadata metadata = new EventMetadata(); + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + return metadata; + } + + string key = reader.GetString(); + reader.Read(); + + object value = reader.TokenType switch + { + JsonTokenType.String => reader.GetString(), + JsonTokenType.Number when reader.TryGetInt32(out int i) => i, + JsonTokenType.Number when reader.TryGetInt64(out long l) => l, + JsonTokenType.Number when reader.TryGetDouble(out double d) => d, + JsonTokenType.True => true, + JsonTokenType.False => false, + JsonTokenType.Null => null, + _ => reader.GetString() + }; + + metadata[key] = value; + } + + throw new JsonException("Unexpected end of JSON"); + } + + public override void Write(Utf8JsonWriter writer, EventMetadata value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + foreach (KeyValuePair kvp in value) + { + writer.WritePropertyName(kvp.Key); + WriteValue(writer, kvp.Value); + } + + writer.WriteEndObject(); + } + + /// + /// Serialize EventMetadata directly using Utf8JsonWriter, bypassing + /// JsonSerializer entirely. Safe for all known EventMetadata value types. + /// + public static string SerializeToString(EventMetadata metadata) + { + using (MemoryStream stream = new MemoryStream()) + { + using (Utf8JsonWriter writer = new Utf8JsonWriter(stream)) + { + writer.WriteStartObject(); + foreach (KeyValuePair kvp in metadata) + { + writer.WritePropertyName(kvp.Key); + WriteValue(writer, kvp.Value); + } + + writer.WriteEndObject(); + } + + return Encoding.UTF8.GetString(stream.ToArray()); + } + } + + private static void WriteValue(Utf8JsonWriter writer, object value) + { + switch (value) + { + case null: + writer.WriteNullValue(); + break; + case string s: + writer.WriteStringValue(s); + break; + case int i: + writer.WriteNumberValue(i); + break; + case long l: + writer.WriteNumberValue(l); + break; + case double d: + writer.WriteNumberValue(d); + break; + case bool b: + writer.WriteBooleanValue(b); + break; + case float f: + writer.WriteNumberValue(f); + break; + case HttpStatusCode status: + writer.WriteNumberValue((int)status); + break; + case Enum e: + writer.WriteStringValue(e.ToString()); + break; + default: + writer.WriteStringValue(value.ToString()); + break; + } + } + } +} diff --git a/GVFS/GVFS.Common/Tracing/JsonTracer.cs b/GVFS/GVFS.Common/Tracing/JsonTracer.cs index 0fb24012c..c494bd012 100644 --- a/GVFS/GVFS.Common/Tracing/JsonTracer.cs +++ b/GVFS/GVFS.Common/Tracing/JsonTracer.cs @@ -1,5 +1,4 @@ -using Newtonsoft.Json; -using System; +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; @@ -275,13 +274,13 @@ public void WriteStartEvent( if (repoUrl != null) { - metadata.Add("Remote", Uri.EscapeUriString(repoUrl)); + metadata.Add("Remote", Uri.EscapeDataString(repoUrl)); } if (cacheServerUrl != null) { // Changing this key to CacheServerUrl will mess with our telemetry, so it stays for historical reasons - metadata.Add("ObjectsEndpoint", Uri.EscapeUriString(cacheServerUrl)); + metadata.Add("ObjectsEndpoint", Uri.EscapeDataString(cacheServerUrl)); } if (additionalMetadata != null) @@ -346,7 +345,7 @@ private static TraceEventMessage CreateListenerRecoveryMessage(EventListener rec Level = EventLevel.Informational, Keywords = Keywords.Any, Opcode = EventOpcode.Info, - Payload = JsonConvert.SerializeObject(new Dictionary + Payload = GVFSJsonOptions.Serialize(new Dictionary { ["EventListener"] = recoveredListener.GetType().Name }) @@ -361,7 +360,7 @@ private static TraceEventMessage CreateListenerFailureMessage(EventListener fail Level = EventLevel.Error, Keywords = Keywords.Any, Opcode = EventOpcode.Info, - Payload = JsonConvert.SerializeObject(new Dictionary + Payload = GVFSJsonOptions.Serialize(new Dictionary { ["EventListener"] = failedListener.GetType().Name, ["ErrorMessage"] = errorMessage, @@ -371,7 +370,7 @@ private static TraceEventMessage CreateListenerFailureMessage(EventListener fail private void WriteEvent(string eventName, EventLevel level, Keywords keywords, EventMetadata metadata, EventOpcode opcode) { - string jsonPayload = metadata != null ? JsonConvert.SerializeObject(metadata) : null; + string jsonPayload = metadata != null ? EventMetadataConverter.SerializeToString(metadata) : null; if (this.isDisposed) { diff --git a/GVFS/GVFS.Common/Tracing/PrettyConsoleEventListener.cs b/GVFS/GVFS.Common/Tracing/PrettyConsoleEventListener.cs index 7f73ddc02..5999d97ee 100644 --- a/GVFS/GVFS.Common/Tracing/PrettyConsoleEventListener.cs +++ b/GVFS/GVFS.Common/Tracing/PrettyConsoleEventListener.cs @@ -1,5 +1,4 @@ -using System; -using Newtonsoft.Json; +using System; namespace GVFS.Common.Tracing { @@ -24,7 +23,7 @@ protected override void RecordMessageInternal(TraceEventMessage message) return; } - ConsoleOutputPayload payload = JsonConvert.DeserializeObject(message.Payload); + ConsoleOutputPayload payload = GVFSJsonOptions.Deserialize(message.Payload); if (string.IsNullOrEmpty(payload.ErrorMessage)) { return; @@ -60,7 +59,7 @@ protected override void RecordMessageInternal(TraceEventMessage message) } } - private class ConsoleOutputPayload + internal class ConsoleOutputPayload { public string ErrorMessage { get; set; } } diff --git a/GVFS/GVFS.Common/Tracing/TelemetryDaemonEventListener.cs b/GVFS/GVFS.Common/Tracing/TelemetryDaemonEventListener.cs index e3640f1c0..35e517a42 100644 --- a/GVFS/GVFS.Common/Tracing/TelemetryDaemonEventListener.cs +++ b/GVFS/GVFS.Common/Tracing/TelemetryDaemonEventListener.cs @@ -1,7 +1,8 @@ -using System; +using System; using System.IO.Pipes; using GVFS.Common.Git; -using Newtonsoft.Json; +using System.Text.Json; +using System.Text.Json.Serialization; namespace GVFS.Common.Tracing { @@ -129,38 +130,38 @@ private string CreatePipeMessage(TraceEventMessage message) public class PipeMessage { - [JsonProperty("version")] + [JsonPropertyName("version")] public string Version { get; set; } - [JsonProperty("providerName")] + [JsonPropertyName("providerName")] public string ProviderName { get; set; } - [JsonProperty("eventName")] + [JsonPropertyName("eventName")] public string EventName { get; set; } - [JsonProperty("eventLevel")] + [JsonPropertyName("eventLevel")] public EventLevel EventLevel { get; set; } - [JsonProperty("eventOpcode")] + [JsonPropertyName("eventOpcode")] public EventOpcode EventOpcode { get; set; } - [JsonProperty("payload")] + [JsonPropertyName("payload")] public PipeMessagePayload Payload { get; set; } public static PipeMessage FromJson(string json) { - return JsonConvert.DeserializeObject(json); + return GVFSJsonOptions.Deserialize(json); } public string ToJson() { - return JsonConvert.SerializeObject(this); + return GVFSJsonOptions.Serialize(this); } public class PipeMessagePayload { - [JsonProperty("enlistmentId")] + [JsonPropertyName("enlistmentId")] public string EnlistmentId { get; set; } - [JsonProperty("mountId")] + [JsonPropertyName("mountId")] public string MountId { get; set; } - [JsonProperty("gitCommandSessionId")] + [JsonPropertyName("gitCommandSessionId")] public string GitCommandSessionId { get; set; } - [JsonProperty("json")] + [JsonPropertyName("json")] public string Json { get; set; } } } diff --git a/GVFS/GVFS.Common/VersionConverter.cs b/GVFS/GVFS.Common/VersionConverter.cs new file mode 100644 index 000000000..6a0c488c1 --- /dev/null +++ b/GVFS/GVFS.Common/VersionConverter.cs @@ -0,0 +1,93 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace GVFS.Common +{ + /// + /// Custom JsonConverter for System.Version that handles both string format ("1.0.0.0") + /// and object format ({"Major":1,"Minor":0,"Build":0,"Revision":0}). + /// + /// Newtonsoft.Json could deserialize System.Version from either format automatically. + /// System.Text.Json has no built-in converter for System.Version, so this is required. + /// + public class VersionConverter : JsonConverter + { + public override Version Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + if (reader.TokenType == JsonTokenType.String) + { + return new Version(reader.GetString()); + } + + if (reader.TokenType == JsonTokenType.StartObject) + { + int major = 0, minor = 0, build = -1, revision = -1; + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + break; + } + + if (reader.TokenType == JsonTokenType.PropertyName) + { + string propertyName = reader.GetString(); + reader.Read(); + + switch (propertyName) + { + case "Major": + major = reader.GetInt32(); + break; + case "Minor": + minor = reader.GetInt32(); + break; + case "Build": + build = reader.GetInt32(); + break; + case "Revision": + revision = reader.GetInt32(); + break; + default: + reader.Skip(); + break; + } + } + } + + if (build < 0) + { + return new Version(major, minor); + } + + if (revision < 0) + { + return new Version(major, minor, build); + } + + return new Version(major, minor, build, revision); + } + + throw new JsonException($"Unexpected token type '{reader.TokenType}' when deserializing System.Version."); + } + + public override void Write(Utf8JsonWriter writer, Version value, JsonSerializerOptions options) + { + if (value == null) + { + writer.WriteNullValue(); + } + else + { + writer.WriteStringValue(value.ToString()); + } + } + } +} diff --git a/GVFS/GVFS.Common/VersionResponse.cs b/GVFS/GVFS.Common/VersionResponse.cs index 4a8a8c29e..08864f5c0 100644 --- a/GVFS/GVFS.Common/VersionResponse.cs +++ b/GVFS/GVFS.Common/VersionResponse.cs @@ -1,4 +1,3 @@ -using Newtonsoft.Json; namespace GVFS.Common { @@ -8,7 +7,7 @@ public class VersionResponse public static VersionResponse FromJsonString(string jsonString) { - return JsonConvert.DeserializeObject(jsonString); + return GVFSJsonOptions.Deserialize(jsonString); } } } diff --git a/GVFS/GVFS.FunctionalTests.LockHolder/AcquireGVFSLock.cs b/GVFS/GVFS.FunctionalTests.LockHolder/AcquireGVFSLock.cs index 2a64fdead..28b62720d 100644 --- a/GVFS/GVFS.FunctionalTests.LockHolder/AcquireGVFSLock.cs +++ b/GVFS/GVFS.FunctionalTests.LockHolder/AcquireGVFSLock.cs @@ -1,8 +1,8 @@ -using CommandLine; using GVFS.Common; using GVFS.Common.NamedPipes; using GVFS.Platform.Windows; using System; +using System.CommandLine; using System.Diagnostics; using System.Runtime.InteropServices; @@ -12,13 +12,25 @@ public class AcquireGVFSLockVerb { private static string fullCommand = "GVFS.FunctionalTests.LockHolder"; - [Option( - "skip-release-lock", - Default = false, - Required = false, - HelpText = "Skip releasing the GVFS lock when exiting the program.")] public bool NoReleaseLock { get; set; } + public static RootCommand BuildRootCommand() + { + RootCommand rootCommand = new RootCommand(); + + Option skipReleaseLockOption = new Option("--skip-release-lock") { Description = "Skip releasing the GVFS lock when exiting the program." }; + rootCommand.Add(skipReleaseLockOption); + + rootCommand.SetAction((ParseResult result) => + { + AcquireGVFSLockVerb verb = new AcquireGVFSLockVerb(); + verb.NoReleaseLock = result.GetValue(skipReleaseLockOption); + verb.Execute(); + }); + + return rootCommand; + } + public void Execute() { string errorMessage; diff --git a/GVFS/GVFS.FunctionalTests.LockHolder/GVFS.FunctionalTests.LockHolder.csproj b/GVFS/GVFS.FunctionalTests.LockHolder/GVFS.FunctionalTests.LockHolder.csproj index bb03a4171..719420227 100644 --- a/GVFS/GVFS.FunctionalTests.LockHolder/GVFS.FunctionalTests.LockHolder.csproj +++ b/GVFS/GVFS.FunctionalTests.LockHolder/GVFS.FunctionalTests.LockHolder.csproj @@ -1,12 +1,12 @@ - + - net471 Exe + false - + @@ -16,3 +16,4 @@ + diff --git a/GVFS/GVFS.FunctionalTests.LockHolder/Program.cs b/GVFS/GVFS.FunctionalTests.LockHolder/Program.cs index 4d6fffbf8..ed0693cba 100644 --- a/GVFS/GVFS.FunctionalTests.LockHolder/Program.cs +++ b/GVFS/GVFS.FunctionalTests.LockHolder/Program.cs @@ -1,4 +1,4 @@ -using CommandLine; +using System.CommandLine; namespace GVFS.FunctionalTests.LockHolder { @@ -6,8 +6,8 @@ public class Program { public static void Main(string[] args) { - Parser.Default.ParseArguments(args) - .WithParsed(acquireGVFSLock => acquireGVFSLock.Execute()); + RootCommand rootCommand = AcquireGVFSLockVerb.BuildRootCommand(); + rootCommand.Parse(args).Invoke(); } } } diff --git a/GVFS/GVFS.FunctionalTests/FileSystemRunners/BashRunner.cs b/GVFS/GVFS.FunctionalTests/FileSystemRunners/BashRunner.cs index e0d75f013..781a96572 100644 --- a/GVFS/GVFS.FunctionalTests/FileSystemRunners/BashRunner.cs +++ b/GVFS/GVFS.FunctionalTests/FileSystemRunners/BashRunner.cs @@ -304,12 +304,12 @@ public override long FileSize(string path) return long.Parse(this.RunProcess(statCommand)); } - public override void CreateFileWithoutClose(string path) + public override IDisposable CreateFileWithoutClose(string path) { throw new NotImplementedException(); } - public override void OpenFileAndWriteWithoutClose(string path, string data) + public override IDisposable OpenFileAndWriteWithoutClose(string path, string data) { throw new NotImplementedException(); } diff --git a/GVFS/GVFS.FunctionalTests/FileSystemRunners/CmdRunner.cs b/GVFS/GVFS.FunctionalTests/FileSystemRunners/CmdRunner.cs index 1fe346f1c..baef1a58a 100644 --- a/GVFS/GVFS.FunctionalTests/FileSystemRunners/CmdRunner.cs +++ b/GVFS/GVFS.FunctionalTests/FileSystemRunners/CmdRunner.cs @@ -238,12 +238,12 @@ public override void ChangeMode(string path, ushort mode) throw new NotSupportedException(); } - public override void CreateFileWithoutClose(string path) - { + public override IDisposable CreateFileWithoutClose(string path) + { throw new NotImplementedException(); - } - - public override void OpenFileAndWriteWithoutClose(string path, string data) + } + + public override IDisposable OpenFileAndWriteWithoutClose(string path, string data) { throw new NotImplementedException(); } diff --git a/GVFS/GVFS.FunctionalTests/FileSystemRunners/FileSystemRunner.cs b/GVFS/GVFS.FunctionalTests/FileSystemRunners/FileSystemRunner.cs index 07f7b983e..a862497da 100644 --- a/GVFS/GVFS.FunctionalTests/FileSystemRunners/FileSystemRunner.cs +++ b/GVFS/GVFS.FunctionalTests/FileSystemRunners/FileSystemRunner.cs @@ -76,8 +76,8 @@ public static FileSystemRunner DefaultRunner /// Path to file /// File contents public abstract void WriteAllText(string path, string contents); - public abstract void CreateFileWithoutClose(string path); - public abstract void OpenFileAndWriteWithoutClose(string path, string data); + public abstract IDisposable CreateFileWithoutClose(string path); + public abstract IDisposable OpenFileAndWriteWithoutClose(string path, string data); /// /// Append the specified contents to the specified file. By calling this method the caller is diff --git a/GVFS/GVFS.FunctionalTests/FileSystemRunners/PowerShellRunner.cs b/GVFS/GVFS.FunctionalTests/FileSystemRunners/PowerShellRunner.cs index e9601cf93..6e9183e97 100644 --- a/GVFS/GVFS.FunctionalTests/FileSystemRunners/PowerShellRunner.cs +++ b/GVFS/GVFS.FunctionalTests/FileSystemRunners/PowerShellRunner.cs @@ -1,4 +1,5 @@ using GVFS.Tests.Should; +using System; using System.IO; namespace GVFS.FunctionalTests.FileSystemRunners @@ -217,12 +218,12 @@ public override void ChangeMode(string path, ushort mode) throw new System.NotSupportedException(); } - public override void CreateFileWithoutClose(string path) + public override IDisposable CreateFileWithoutClose(string path) { throw new System.NotSupportedException(); - } - - public override void OpenFileAndWriteWithoutClose(string path, string data) + } + + public override IDisposable OpenFileAndWriteWithoutClose(string path, string data) { throw new System.NotSupportedException(); } diff --git a/GVFS/GVFS.FunctionalTests/FileSystemRunners/SystemIORunner.cs b/GVFS/GVFS.FunctionalTests/FileSystemRunners/SystemIORunner.cs index 2b0e4ac61..e8c7f8f98 100644 --- a/GVFS/GVFS.FunctionalTests/FileSystemRunners/SystemIORunner.cs +++ b/GVFS/GVFS.FunctionalTests/FileSystemRunners/SystemIORunner.cs @@ -22,15 +22,17 @@ public override string MoveFile(string sourcePath, string targetPath) return string.Empty; } - public override void CreateFileWithoutClose(string path) + public override IDisposable CreateFileWithoutClose(string path) { - File.Create(path); - } - - public override void OpenFileAndWriteWithoutClose(string path, string content) + return File.Create(path); + } + + public override IDisposable OpenFileAndWriteWithoutClose(string path, string content) { StreamWriter file = new StreamWriter(path); file.Write(content); + file.Flush(); + return file; } public override void MoveFileShouldFail(string sourcePath, string targetPath) diff --git a/GVFS/GVFS.FunctionalTests/GVFS.FunctionalTests.csproj b/GVFS/GVFS.FunctionalTests/GVFS.FunctionalTests.csproj index c777bdf84..4d60d7b54 100644 --- a/GVFS/GVFS.FunctionalTests/GVFS.FunctionalTests.csproj +++ b/GVFS/GVFS.FunctionalTests/GVFS.FunctionalTests.csproj @@ -1,18 +1,16 @@ - + - net471 Exe + false - - - - - - - + + + + + @@ -23,10 +21,8 @@ Content PreserveNewest - - false - + PreserveNewest @@ -36,3 +32,4 @@ + diff --git a/GVFS/GVFS.FunctionalTests/Program.cs b/GVFS/GVFS.FunctionalTests/Program.cs index 0303371bf..07ecfa402 100644 --- a/GVFS/GVFS.FunctionalTests/Program.cs +++ b/GVFS/GVFS.FunctionalTests/Program.cs @@ -130,6 +130,8 @@ public static void Main(string[] args) ?? Properties.Settings.Default.RepoToClone; RunBeforeAnyTests(); + Console.WriteLine("[CI-DEBUG] RunBeforeAnyTests complete, starting RunTests..."); + Console.Out.Flush(); Environment.ExitCode = runner.RunTests(includeCategories, excludeCategories, testSlice); if (Debugger.IsAttached) @@ -141,12 +143,19 @@ public static void Main(string[] args) private static void RunBeforeAnyTests() { + Console.WriteLine("[CI-DEBUG] RunBeforeAnyTests: starting"); + Console.Out.Flush(); + if (GVFSTestConfig.ReplaceInboxProjFS) { ProjFSFilterInstaller.ReplaceInboxProjFS(); } + Console.WriteLine("[CI-DEBUG] Installing service..."); + Console.Out.Flush(); GVFSServiceProcess.InstallService(); + Console.WriteLine("[CI-DEBUG] Service installed successfully"); + Console.Out.Flush(); string serviceProgramDataDir = GVFSPlatform.Instance.GetSecureDataRootForGVFSComponent( GVFSConstants.Service.ServiceName); @@ -159,6 +168,9 @@ private static void RunBeforeAnyTests() Directory.CreateDirectory(serviceProgramDataDir); File.WriteAllText(statusCacheVersionTokenPath, string.Empty); } + + Console.WriteLine("[CI-DEBUG] RunBeforeAnyTests: complete"); + Console.Out.Flush(); } } } diff --git a/GVFS/GVFS.FunctionalTests/Settings.cs b/GVFS/GVFS.FunctionalTests/Settings.cs index 9a978d2cf..4bd933790 100644 --- a/GVFS/GVFS.FunctionalTests/Settings.cs +++ b/GVFS/GVFS.FunctionalTests/Settings.cs @@ -32,8 +32,10 @@ public static class Default public static void Initialize() { - string testExec = System.Reflection.Assembly.GetEntryAssembly().Location; - CurrentDirectory = Path.GetFullPath(Path.GetDirectoryName(testExec)); + string testExec = Environment.ProcessPath; + CurrentDirectory = string.IsNullOrEmpty(testExec) + ? AppContext.BaseDirectory + : Path.GetFullPath(Path.GetDirectoryName(testExec)); RepoToClone = @"https://gvfs.visualstudio.com/ci/_git/ForTests"; diff --git a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/BasicFileSystemTests.cs b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/BasicFileSystemTests.cs index 1a8925ed1..d19b3e982 100644 --- a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/BasicFileSystemTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/BasicFileSystemTests.cs @@ -133,49 +133,132 @@ public void NewFolderAttributesAreUpdated(string parentFolder) Directory.Delete(virtualFolder); } + // On .NET 10, no FileInfo property setter (CreationTime, LastAccessTime, LastWriteTime, + // Attributes) triggers ProjFS hydration. Only actual file content I/O (read+write) does. + // These tests replace the original ExpandedFileAttributesAreUpdated test, which relied on + // .NET Framework 4.7.1's CreationTime setter triggering hydration as a side effect. + + /// + /// Hydrates a ProjFS placeholder by reading and writing its content, then waits for + /// ProjFS to clear the RecallOnDataAccess flag (which happens asynchronously). + /// Uses FileStream with FileMode.Open since File.WriteAllText fails on Hidden files. + /// + private static void HydrateFile(string virtualFile) + { + using (FileStream fs = new FileStream(virtualFile, FileMode.Open, FileAccess.ReadWrite, FileShare.None)) + { + byte[] buf = new byte[fs.Length]; + fs.Read(buf, 0, buf.Length); + fs.Position = 0; + fs.Write(buf, 0, buf.Length); + } + + // ProjFS clears RecallOnDataAccess asynchronously after hydration. + // Wait for it to complete — CI machines can be slow. + int retryCount = 0; + while (retryCount < 10) + { + FileAttributes attrs = File.GetAttributes(virtualFile); + if (((int)attrs & FileAttributeRecallOnDataAccess) == 0) + { + return; + } + + ++retryCount; + Thread.Sleep(500); + } + + File.GetAttributes(virtualFile).ShouldNotEqual( + (FileAttributes)FileAttributeRecallOnDataAccess, + "File should be hydrated (no RecallOnDataAccess) after content write and retry"); + } + [TestCase] - public void ExpandedFileAttributesAreUpdated() + public void PlaceholderMetadataSurvivesHydration() { + // Set all metadata properties on a ProjFS placeholder, verify they took effect + // while the file is still a placeholder, then hydrate via content I/O and verify + // the values survived the placeholder-to-full-file conversion. FileSystemRunner fileSystem = FileSystemRunner.DefaultRunner; string filename = Path.Combine("GVFS", "GVFS", "GVFS.csproj"); string virtualFile = this.Enlistment.GetVirtualPathTo(filename); - - // Update defaults. FileInfo is not batched, so each of these will create a separate Open-Update-Close set. - FileInfo before = new FileInfo(virtualFile); DateTime testValue = DateTime.Now + TimeSpan.FromDays(1); - // Setting the CreationTime results in a write handle being open to the file and the file being expanded - before.CreationTime = testValue; - before.LastAccessTime = testValue; - before.LastWriteTime = testValue; - before.Attributes = FileAttributes.Hidden; - - // FileInfo caches information. We can refresh, but just to be absolutely sure... - FileInfo info = virtualFile.ShouldBeAFile(fileSystem).WithInfo(testValue, testValue, testValue); - - // Ignore the archive bit as it can be re-added to the file as part of its expansion to full - FileAttributes attributes = info.Attributes & ~FileAttributes.Archive; - + // Set all properties while file is still a placeholder + FileInfo fi = new FileInfo(virtualFile); + fi.CreationTime = testValue; + fi.LastAccessTime = testValue; + fi.LastWriteTime = testValue; + fi.Attributes = FileAttributes.Hidden; + + // Verify file is still a placeholder (no property setter triggers hydration on .NET 10) + fi.Refresh(); + ((int)fi.Attributes & FileAttributeRecallOnDataAccess).ShouldNotEqual( + 0, + "File should still be a placeholder after setting metadata properties"); + + // Verify the properties took effect on the placeholder + fi.CreationTime.ShouldEqual(testValue, "CreationTime should be set on placeholder"); + fi.LastAccessTime.ShouldEqual(testValue, "LastAccessTime should be set on placeholder"); + fi.LastWriteTime.ShouldEqual(testValue, "LastWriteTime should be set on placeholder"); + FileAttributes placeholderAttrs = fi.Attributes & ~FileAttributes.Archive & (FileAttributes)~(FileAttributeSparseFile | FileAttributeReparsePoint | FileAttributeRecallOnDataAccess); + placeholderAttrs.ShouldEqual(FileAttributes.Hidden, $"Hidden should be set on placeholder, got: {placeholderAttrs}"); + + // Hydrate and wait for ProjFS to finish clearing placeholder flags + HydrateFile(virtualFile); + + // Verify metadata survived hydration. + // CreationTime should survive — it's not affected by read or write operations. + fi.Refresh(); + fi.CreationTime.ShouldEqual(testValue, "CreationTime should survive hydration"); + + // LastAccessTime and LastWriteTime are inherently updated by the read+write + // hydration step, so we cannot assert the pre-hydration values survived. + + // Hidden attribute should survive hydration (with async ProjFS flag cleanup) int retryCount = 0; - int maxRetries = 10; - while (attributes != FileAttributes.Hidden && retryCount < maxRetries) + FileAttributes attributes = fi.Attributes & ~FileAttributes.Archive; + while (attributes != FileAttributes.Hidden && retryCount < 10) { - // ProjFS attributes are remoted asynchronously when files are converted to full - FileAttributes attributesLessProjFS = attributes & (FileAttributes)~(FileAttributeSparseFile | FileAttributeReparsePoint | FileAttributeRecallOnDataAccess); - - attributesLessProjFS.ShouldEqual( + FileAttributes withoutProjFS = attributes & (FileAttributes)~(FileAttributeSparseFile | FileAttributeReparsePoint | FileAttributeRecallOnDataAccess); + withoutProjFS.ShouldEqual( FileAttributes.Hidden, - $"Attributes (ignoring ProjFS attributes) do not match, expected: {FileAttributes.Hidden} actual: {attributesLessProjFS}"); - + $"Attributes (ignoring ProjFS) should be Hidden, got: {withoutProjFS}"); ++retryCount; Thread.Sleep(500); - - info.Refresh(); - attributes = info.Attributes & ~FileAttributes.Archive; + fi.Refresh(); + attributes = fi.Attributes & ~FileAttributes.Archive; } - attributes.ShouldEqual(FileAttributes.Hidden, $"Attributes do not match, expected: {FileAttributes.Hidden} actual: {attributes}"); + attributes.ShouldEqual(FileAttributes.Hidden, $"Hidden should survive hydration, got: {attributes}"); + } + + [TestCase] + public void HydratedFileTimestampsAndAttributesAreUpdated() + { + // Verify that all timestamps and attributes can be set on an already-hydrated + // (dirty full) file in a GVFS enlistment. + FileSystemRunner fileSystem = FileSystemRunner.DefaultRunner; + + string filename = Path.Combine("GVFS", "GVFS.Common", "GVFSConstants.cs"); + string virtualFile = this.Enlistment.GetVirtualPathTo(filename); + DateTime testValue = DateTime.Now + TimeSpan.FromDays(1); + + // Hydrate and wait for ProjFS to finish clearing placeholder flags + HydrateFile(virtualFile); + + // Set all properties on the now-hydrated file + FileInfo fi = new FileInfo(virtualFile); + fi.CreationTime = testValue; + fi.LastAccessTime = testValue; + fi.LastWriteTime = testValue; + fi.Attributes = FileAttributes.Hidden; + + // Verify all properties stuck + FileInfo verify = virtualFile.ShouldBeAFile(fileSystem).WithInfo(testValue, testValue, testValue); + FileAttributes attributes = verify.Attributes & ~FileAttributes.Archive; + attributes.ShouldEqual(FileAttributes.Hidden, $"Attributes should be Hidden, got: {attributes}"); } [TestCase] diff --git a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/HealthTests.cs b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/HealthTests.cs index d16cda74c..1e9b6926d 100644 --- a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/HealthTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/HealthTests.cs @@ -160,9 +160,18 @@ private void ValidateHealthOutputValues( List directoryHydrationLevels, string enlistmentHealthStatus) { - List healthOutputLines = new List(this.Enlistment.Health(directory).Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries)); + string rawOutput = this.Enlistment.Health(directory); + List healthOutputLines = new List(rawOutput.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries)); int numberOfExpectedSubdirectories = topHydratedDirectories.Count; + int expectedMinimumLines = 8 + numberOfExpectedSubdirectories; + + if (healthOutputLines.Count < expectedMinimumLines) + { + Assert.Fail( + $"Expected at least {expectedMinimumLines} lines in 'gvfs health' output, but got {healthOutputLines.Count}.\n" + + $"Raw output:\n{rawOutput}"); + } this.ValidateTargetDirectory(healthOutputLines[1], directory); this.ValidateTotalFileInfo(healthOutputLines[2], totalFiles, totalFilePercent); diff --git a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs index 376796350..5d277d238 100644 --- a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs @@ -1,9 +1,15 @@ -using GVFS.FunctionalTests.Tools; +using GVFS.Common; +using GVFS.Common.NamedPipes; +using GVFS.FunctionalTests.Tools; using GVFS.Tests.Should; using NUnit.Framework; using System; using System.Diagnostics; using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using ProcessResult = GVFS.FunctionalTests.Tools.ProcessResult; namespace GVFS.FunctionalTests.Tests.EnlistmentPerFixture { @@ -11,102 +17,298 @@ namespace GVFS.FunctionalTests.Tests.EnlistmentPerFixture [Category(Categories.GitCommands)] public class WorktreeTests : TestsWithEnlistmentPerFixture { - private const string WorktreeBranchA = "worktree-test-branch-a"; - private const string WorktreeBranchB = "worktree-test-branch-b"; + private const int MinWorktreeCount = 4; [TestCase] public void ConcurrentWorktreeAddCommitRemove() { - string worktreePathA = Path.Combine(this.Enlistment.EnlistmentRoot, "test-wt-a-" + Guid.NewGuid().ToString("N").Substring(0, 8)); - string worktreePathB = Path.Combine(this.Enlistment.EnlistmentRoot, "test-wt-b-" + Guid.NewGuid().ToString("N").Substring(0, 8)); + int count = Math.Max(Environment.ProcessorCount, MinWorktreeCount); + string[] worktreePaths; + string[] branchNames; + + // Adaptively scale down if concurrent adds overwhelm the primary + // GVFS mount. CI runners with fewer resources may not handle as + // many concurrent git operations as a developer workstation. + while (true) + { + this.InitWorktreeArrays(count, out worktreePaths, out branchNames); + ProcessResult[] addResults = this.ConcurrentWorktreeAdd(worktreePaths, branchNames, count); + + bool overloaded = addResults.Any(r => + r.ExitCode != 0 && + r.Errors != null && + r.Errors.Contains("does not appear to be mounted")); + + // Only retry if ALL failures are overload-related. If any + // failure has a different cause, it's a real regression and + // must not be masked by retrying at lower concurrency. + bool hasNonOverloadFailure = addResults.Any(r => + r.ExitCode != 0 && + !(r.Errors != null && r.Errors.Contains("does not appear to be mounted"))); + + if (hasNonOverloadFailure) + { + // Fall through to the assertion loop below which will + // report the specific failure(s). + } + else if (overloaded) + { + this.CleanupAllWorktrees(worktreePaths, branchNames, count); + int reduced = count / 2; + if (reduced < MinWorktreeCount) + { + Assert.Fail( + $"Primary GVFS mount overloaded even at count={count}. " + + $"Cannot reduce below {MinWorktreeCount}."); + } + + count = reduced; + continue; + } + + // Non-overload failures are real errors + for (int i = 0; i < count; i++) + { + addResults[i].ExitCode.ShouldEqual(0, + $"worktree add [{i}] failed: {addResults[i].Errors}"); + } + + break; + } try { - // 1. Create both worktrees in parallel - ProcessResult addResultA = null; - ProcessResult addResultB = null; - System.Threading.Tasks.Parallel.Invoke( - () => addResultA = GitHelpers.InvokeGitAgainstGVFSRepo( - this.Enlistment.RepoRoot, - $"worktree add -b {WorktreeBranchA} \"{worktreePathA}\""), - () => addResultB = GitHelpers.InvokeGitAgainstGVFSRepo( - this.Enlistment.RepoRoot, - $"worktree add -b {WorktreeBranchB} \"{worktreePathB}\"")); - - addResultA.ExitCode.ShouldEqual(0, $"worktree add A failed: {addResultA.Errors}"); - addResultB.ExitCode.ShouldEqual(0, $"worktree add B failed: {addResultB.Errors}"); - - // 2. Verify both have projected files - Directory.Exists(worktreePathA).ShouldBeTrue("Worktree A directory should exist"); - Directory.Exists(worktreePathB).ShouldBeTrue("Worktree B directory should exist"); - File.Exists(Path.Combine(worktreePathA, "Readme.md")).ShouldBeTrue("Readme.md should be projected in A"); - File.Exists(Path.Combine(worktreePathB, "Readme.md")).ShouldBeTrue("Readme.md should be projected in B"); - - // 3. Verify git status is clean in both - ProcessResult statusA = GitHelpers.InvokeGitAgainstGVFSRepo(worktreePathA, "status --porcelain"); - ProcessResult statusB = GitHelpers.InvokeGitAgainstGVFSRepo(worktreePathB, "status --porcelain"); - statusA.ExitCode.ShouldEqual(0, $"git status A failed: {statusA.Errors}"); - statusB.ExitCode.ShouldEqual(0, $"git status B failed: {statusB.Errors}"); - statusA.Output.Trim().ShouldBeEmpty("Worktree A should have clean status"); - statusB.Output.Trim().ShouldBeEmpty("Worktree B should have clean status"); - - // 4. Verify worktree list shows all three + // 2. Primary assertion: verify GVFS mount is running for each + // worktree by probing the worktree-specific named pipe. + for (int i = 0; i < count; i++) + { + this.AssertWorktreeMounted(worktreePaths[i], $"worktree [{i}]"); + } + + // 3. Verify projected files are visible (secondary assertion) + for (int i = 0; i < count; i++) + { + Directory.Exists(worktreePaths[i]).ShouldBeTrue( + $"Worktree [{i}] directory should exist"); + File.Exists(Path.Combine(worktreePaths[i], "Readme.md")).ShouldBeTrue( + $"Readme.md should be projected in [{i}]"); + } + + // 4. Verify git status is clean in each worktree + for (int i = 0; i < count; i++) + { + ProcessResult status = GitHelpers.InvokeGitAgainstGVFSRepo( + worktreePaths[i], "status --porcelain"); + status.ExitCode.ShouldEqual(0, + $"git status [{i}] failed: {status.Errors}"); + status.Output.Trim().ShouldBeEmpty( + $"Worktree [{i}] should have clean status"); + } + + // 5. Verify worktree list shows all entries ProcessResult listResult = GitHelpers.InvokeGitAgainstGVFSRepo( this.Enlistment.RepoRoot, "worktree list"); listResult.ExitCode.ShouldEqual(0, $"worktree list failed: {listResult.Errors}"); string listOutput = listResult.Output; - Assert.IsTrue(listOutput.Contains(worktreePathA.Replace('\\', '/')), - $"worktree list should contain A. Output: {listOutput}"); - Assert.IsTrue(listOutput.Contains(worktreePathB.Replace('\\', '/')), - $"worktree list should contain B. Output: {listOutput}"); - - // 5. Make commits in both worktrees - File.WriteAllText(Path.Combine(worktreePathA, "from-a.txt"), "created in worktree A"); - GitHelpers.InvokeGitAgainstGVFSRepo(worktreePathA, "add from-a.txt") - .ExitCode.ShouldEqual(0); - GitHelpers.InvokeGitAgainstGVFSRepo(worktreePathA, "commit -m \"commit from A\"") - .ExitCode.ShouldEqual(0); - - File.WriteAllText(Path.Combine(worktreePathB, "from-b.txt"), "created in worktree B"); - GitHelpers.InvokeGitAgainstGVFSRepo(worktreePathB, "add from-b.txt") - .ExitCode.ShouldEqual(0); - GitHelpers.InvokeGitAgainstGVFSRepo(worktreePathB, "commit -m \"commit from B\"") - .ExitCode.ShouldEqual(0); - - // 6. Verify commits are visible from all worktrees (shared objects) - GitHelpers.InvokeGitAgainstGVFSRepo(this.Enlistment.RepoRoot, $"log -1 --format=%s {WorktreeBranchA}") - .Output.ShouldContain(expectedSubstrings: new[] { "commit from A" }); - GitHelpers.InvokeGitAgainstGVFSRepo(this.Enlistment.RepoRoot, $"log -1 --format=%s {WorktreeBranchB}") - .Output.ShouldContain(expectedSubstrings: new[] { "commit from B" }); - - // A can see B's commit and vice versa - GitHelpers.InvokeGitAgainstGVFSRepo(worktreePathA, $"log -1 --format=%s {WorktreeBranchB}") - .Output.ShouldContain(expectedSubstrings: new[] { "commit from B" }); - GitHelpers.InvokeGitAgainstGVFSRepo(worktreePathB, $"log -1 --format=%s {WorktreeBranchA}") - .Output.ShouldContain(expectedSubstrings: new[] { "commit from A" }); - - // 7. Remove both in parallel - ProcessResult removeA = null; - ProcessResult removeB = null; - System.Threading.Tasks.Parallel.Invoke( - () => removeA = GitHelpers.InvokeGitAgainstGVFSRepo( - this.Enlistment.RepoRoot, - $"worktree remove --force \"{worktreePathA}\""), - () => removeB = GitHelpers.InvokeGitAgainstGVFSRepo( - this.Enlistment.RepoRoot, - $"worktree remove --force \"{worktreePathB}\"")); - - removeA.ExitCode.ShouldEqual(0, $"worktree remove A failed: {removeA.Errors}"); - removeB.ExitCode.ShouldEqual(0, $"worktree remove B failed: {removeB.Errors}"); - - // 8. Verify cleanup - Directory.Exists(worktreePathA).ShouldBeFalse("Worktree A directory should be deleted"); - Directory.Exists(worktreePathB).ShouldBeFalse("Worktree B directory should be deleted"); + for (int i = 0; i < count; i++) + { + Assert.IsTrue( + listOutput.Contains(worktreePaths[i].Replace('\\', '/')), + $"worktree list should contain [{i}]. Output: {listOutput}"); + } + + // 6. Make commits in all worktrees + for (int i = 0; i < count; i++) + { + File.WriteAllText( + Path.Combine(worktreePaths[i], $"from-{i}.txt"), + $"created in worktree {i}"); + GitHelpers.InvokeGitAgainstGVFSRepo(worktreePaths[i], $"add from-{i}.txt") + .ExitCode.ShouldEqual(0); + GitHelpers.InvokeGitAgainstGVFSRepo( + worktreePaths[i], $"commit -m \"commit from {i}\"") + .ExitCode.ShouldEqual(0); + } + + // 7. Verify commits are visible from main repo + for (int i = 0; i < count; i++) + { + GitHelpers.InvokeGitAgainstGVFSRepo( + this.Enlistment.RepoRoot, $"log -1 --format=%s {branchNames[i]}") + .Output.ShouldContain(expectedSubstrings: new[] { $"commit from {i}" }); + } + + // 8. Verify cross-worktree commit visibility (shared objects) + for (int i = 0; i < count; i++) + { + int other = (i + 1) % count; + GitHelpers.InvokeGitAgainstGVFSRepo( + worktreePaths[i], $"log -1 --format=%s {branchNames[other]}") + .Output.ShouldContain(expectedSubstrings: new[] { $"commit from {other}" }); + } + + // 9. Remove all worktrees in parallel + ProcessResult[] removeResults = new ProcessResult[count]; + using (CountdownEvent barrier = new CountdownEvent(count)) + { + Thread[] threads = new Thread[count]; + for (int i = 0; i < count; i++) + { + int idx = i; + threads[idx] = new Thread(() => + { + barrier.Signal(); + barrier.Wait(); + removeResults[idx] = GitHelpers.InvokeGitAgainstGVFSRepo( + this.Enlistment.RepoRoot, + $"worktree remove --force \"{worktreePaths[idx]}\""); + }); + threads[idx].Start(); + } + + foreach (Thread t in threads) + { + t.Join(); + } + } + + for (int i = 0; i < count; i++) + { + removeResults[i].ExitCode.ShouldEqual(0, + $"worktree remove [{i}] failed: {removeResults[i].Errors}"); + } + + // 10. Verify cleanup + for (int i = 0; i < count; i++) + { + Directory.Exists(worktreePaths[i]).ShouldBeFalse( + $"Worktree [{i}] directory should be deleted"); + } } finally { - this.ForceCleanupWorktree(worktreePathA, WorktreeBranchA); - this.ForceCleanupWorktree(worktreePathB, WorktreeBranchB); + this.CleanupAllWorktrees(worktreePaths, branchNames, count); + } + } + + private void InitWorktreeArrays(int count, out string[] paths, out string[] branches) + { + paths = new string[count]; + branches = new string[count]; + for (int i = 0; i < count; i++) + { + string suffix = Guid.NewGuid().ToString("N").Substring(0, 8); + paths[i] = Path.Combine(this.Enlistment.EnlistmentRoot, $"test-wt-{i}-{suffix}"); + branches[i] = $"worktree-test-branch-{i}-{suffix}"; + } + } + + private ProcessResult[] ConcurrentWorktreeAdd(string[] paths, string[] branches, int count) + { + ProcessResult[] results = new ProcessResult[count]; + using (CountdownEvent barrier = new CountdownEvent(count)) + { + Thread[] threads = new Thread[count]; + for (int i = 0; i < count; i++) + { + int idx = i; + threads[idx] = new Thread(() => + { + barrier.Signal(); + barrier.Wait(); + results[idx] = GitHelpers.InvokeGitAgainstGVFSRepo( + this.Enlistment.RepoRoot, + $"worktree add -b {branches[idx]} \"{paths[idx]}\""); + }); + threads[idx].Start(); + } + + foreach (Thread t in threads) + { + t.Join(); + } + } + + return results; + } + + /// + /// Asserts that the GVFS mount for a worktree is running by probing + /// the worktree-specific named pipe. This is the definitive signal + /// that ProjFS projection is active — much stronger than File.Exists + /// which depends on projection timing. + /// + private void AssertWorktreeMounted(string worktreePath, string label) + { + string basePipeName = GVFSPlatform.Instance.GetNamedPipeName( + this.Enlistment.EnlistmentRoot); + string suffix = GVFSEnlistment.GetWorktreePipeSuffix(worktreePath); + + Assert.IsNotNull(suffix, + $"Could not determine pipe suffix for {label} at {worktreePath}. " + + $"The worktree .git file may be missing or malformed."); + + string pipeName = basePipeName + suffix; + + using (NamedPipeClient client = new NamedPipeClient(pipeName)) + { + if (!client.Connect(10000)) + { + string diagnostics = this.CaptureWorktreeDiagnostics(worktreePath); + Assert.Fail( + $"GVFS mount is NOT running for {label}.\n" + + $"Path: {worktreePath}\n" + + $"Pipe: {pipeName}\n" + + $"This indicates the post-hook 'gvfs mount' failed silently.\n" + + $"Diagnostics:\n{diagnostics}"); + } + } + } + + private string CaptureWorktreeDiagnostics(string worktreePath) + { + StringBuilder sb = new StringBuilder(); + + sb.AppendLine($" Directory exists: {Directory.Exists(worktreePath)}"); + if (Directory.Exists(worktreePath)) + { + string dotGit = Path.Combine(worktreePath, ".git"); + sb.AppendLine($" .git file exists: {File.Exists(dotGit)}"); + if (File.Exists(dotGit)) + { + try + { + sb.AppendLine($" .git contents: {File.ReadAllText(dotGit).Trim()}"); + } + catch (Exception ex) + { + sb.AppendLine($" .git read failed: {ex.Message}"); + } + } + + try + { + string[] entries = Directory.GetFileSystemEntries(worktreePath); + sb.AppendLine($" Directory listing ({entries.Length} entries):"); + foreach (string entry in entries) + { + sb.AppendLine($" {Path.GetFileName(entry)}"); + } + } + catch (Exception ex) + { + sb.AppendLine($" Directory listing failed: {ex.Message}"); + } + } + + return sb.ToString(); + } + + private void CleanupAllWorktrees(string[] paths, string[] branches, int count) + { + for (int i = 0; i < count; i++) + { + this.ForceCleanupWorktree(paths[i], branches[i]); } } diff --git a/GVFS/GVFS.FunctionalTests/Tests/GitCommands/GitCommandsTests.cs b/GVFS/GVFS.FunctionalTests/Tests/GitCommands/GitCommandsTests.cs index 28f752353..2af41d00f 100644 --- a/GVFS/GVFS.FunctionalTests/Tests/GitCommands/GitCommandsTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/GitCommands/GitCommandsTests.cs @@ -999,18 +999,22 @@ public void EditFileNeedingUtf8Encoding() [TestCase] public void ChangeTimestampAndDiff() { - // User scenario - - // 1. Enlistment's "diff.autoRefreshIndex" config is set to false - // 2. A checked out file got into a state where it differs from the git copy - // only in its LastWriteTime metadata (no change in file contents.) - // Repro steps - This happens when user edits a file, saves it and later decides - // to undo the edit and save the file again. - // Once in this state, the unchanged file (only its timestamp has changed) shows - // up in `git difftool` creating noise. It also shows up in `git diff --raw` command, - // (but not in `git status` or `git diff`.) - - // Change the timestamp - The lastwrite time can be close to the time this test method gets - // run. Changing (Subtracting) it to the past so there will always be a difference. + // User scenario: a checked-out file gets into a state where it differs + // from the git copy only in its LastWriteTime (no content change). + // This happens when a user edits a file, saves, undoes the edit, and saves again. + // The unchanged file then shows up in `git diff --raw` and `git difftool`. + + // Simulate the user editing and undoing: read the file, write it back unchanged. + // This hydrates the ProjFS placeholder into a full file, which is the normal + // state a user would be in before the timestamp-only scenario occurs. + // (.NET 10's File.SetLastWriteTime no longer triggers ProjFS hydration + // the way .NET Framework 4.7.1 did, so we must hydrate explicitly.) + string virtualFile = Path.Combine(this.Enlistment.RepoRoot, GitCommandsTests.EditFilePath); + string controlFile = Path.Combine(this.ControlGitRepo.RootPath, GitCommandsTests.EditFilePath); + string originalContent = File.ReadAllText(virtualFile); + File.WriteAllText(virtualFile, originalContent); + File.WriteAllText(controlFile, File.ReadAllText(controlFile)); + this.AdjustLastWriteTime(GitCommandsTests.EditFilePath, TimeSpan.FromDays(-10)); this.ValidateGitCommand("diff --raw"); this.ValidateGitCommand($"checkout {GitCommandsTests.EditFilePath}"); diff --git a/GVFS/GVFS.FunctionalTests/Tests/GitCommands/GitRepoTests.cs b/GVFS/GVFS.FunctionalTests/Tests/GitCommands/GitRepoTests.cs index d7a22fa28..64aa7a668 100644 --- a/GVFS/GVFS.FunctionalTests/Tests/GitCommands/GitRepoTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/GitCommands/GitRepoTests.cs @@ -129,6 +129,10 @@ public virtual void TearDownForFixture() [SetUp] public virtual void SetupForTest() { + string testName = TestContext.CurrentContext.Test.FullName; + Console.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] [TEST-SETUP-START] {testName}"); + Console.Out.Flush(); + if (this.enlistmentPerTest) { this.CreateEnlistment(); @@ -151,12 +155,22 @@ public virtual void SetupForTest() } this.ValidateGitCommand("status"); + + Console.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] [TEST-SETUP-END] {testName}"); + Console.Out.Flush(); } [TearDown] public virtual void TearDownForTest() { + string testName = TestContext.CurrentContext.Test.FullName; + Console.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] [TEST-TEARDOWN-START] {testName}"); + Console.Out.Flush(); + this.TestValidationAndCleanup(); + + Console.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] [TEST-TEARDOWN-END] {testName}"); + Console.Out.Flush(); } protected void TestValidationAndCleanup(bool ignoreCase = false) @@ -312,22 +326,24 @@ protected void CreateFile(string content, params string[] filePathPaths) this.FileSystem.WriteAllText(controlFile, content); } - protected void CreateFileWithoutClose(string path) - { + protected IDisposable CreateFileWithoutClose(string path) + { string virtualFile = Path.Combine(this.Enlistment.RepoRoot, path); string controlFile = Path.Combine(this.ControlGitRepo.RootPath, path); - this.FileSystem.CreateFileWithoutClose(virtualFile); - this.FileSystem.CreateFileWithoutClose(controlFile); - } - - protected void ReadFileAndWriteWithoutClose(string path, string contents) + IDisposable virtualHandle = this.FileSystem.CreateFileWithoutClose(virtualFile); + IDisposable controlHandle = this.FileSystem.CreateFileWithoutClose(controlFile); + return new CompositeDisposable(virtualHandle, controlHandle); + } + + protected IDisposable ReadFileAndWriteWithoutClose(string path, string contents) { string virtualFile = Path.Combine(this.Enlistment.RepoRoot, path); string controlFile = Path.Combine(this.ControlGitRepo.RootPath, path); this.FileSystem.ReadAllText(virtualFile); this.FileSystem.ReadAllText(controlFile); - this.FileSystem.OpenFileAndWriteWithoutClose(virtualFile, contents); - this.FileSystem.OpenFileAndWriteWithoutClose(controlFile, contents); + IDisposable virtualHandle = this.FileSystem.OpenFileAndWriteWithoutClose(virtualFile, contents); + IDisposable controlHandle = this.FileSystem.OpenFileAndWriteWithoutClose(controlFile, contents); + return new CompositeDisposable(virtualHandle, controlHandle); } protected void CreateFolder(string folderPath) @@ -653,5 +669,27 @@ protected void FilesShouldMatchAfterConflict() this.FileContentsShouldMatch("Test_ConflictTests", "ModifiedFiles", "SameChange.txt"); this.FileContentsShouldMatch("Test_ConflictTests", "ModifiedFiles", "SuccessfulMerge.txt"); } + + /// + /// Disposes multiple objects as a single unit. + /// Used to hold file handles open for the duration of a test scope. + /// + protected sealed class CompositeDisposable : IDisposable + { + private readonly IDisposable[] disposables; + + public CompositeDisposable(params IDisposable[] disposables) + { + this.disposables = disposables; + } + + public void Dispose() + { + foreach (IDisposable disposable in this.disposables) + { + disposable?.Dispose(); + } + } + } } } diff --git a/GVFS/GVFS.FunctionalTests/Tests/GitCommands/StatusTests.cs b/GVFS/GVFS.FunctionalTests/Tests/GitCommands/StatusTests.cs index 65edacc6d..6cf5a78b3 100644 --- a/GVFS/GVFS.FunctionalTests/Tests/GitCommands/StatusTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/GitCommands/StatusTests.cs @@ -46,16 +46,20 @@ public void DeleteThenCreateThenDeleteFile() public void CreateFileWithoutClose() { string srcPath = @"CreateFileWithoutClose.md"; - this.CreateFileWithoutClose(srcPath); - this.ValidGitStatusWithRetry(srcPath); + using (IDisposable handles = this.CreateFileWithoutClose(srcPath)) + { + this.ValidGitStatusWithRetry(srcPath); + } } [TestCase] public void WriteWithoutClose() { string srcPath = @"Readme.md"; - this.ReadFileAndWriteWithoutClose(srcPath, "More Stuff"); - this.ValidGitStatusWithRetry(srcPath); + using (IDisposable handles = this.ReadFileAndWriteWithoutClose(srcPath, "More Stuff")) + { + this.ValidGitStatusWithRetry(srcPath); + } } [TestCase] diff --git a/GVFS/GVFS.FunctionalTests/Tools/GVFSFunctionalTestEnlistment.cs b/GVFS/GVFS.FunctionalTests/Tools/GVFSFunctionalTestEnlistment.cs index 854b25a2d..40ee7156c 100644 --- a/GVFS/GVFS.FunctionalTests/Tools/GVFSFunctionalTestEnlistment.cs +++ b/GVFS/GVFS.FunctionalTests/Tools/GVFSFunctionalTestEnlistment.cs @@ -2,12 +2,12 @@ using GVFS.FunctionalTests.Should; using GVFS.FunctionalTests.Tests; using GVFS.Tests.Should; -using Newtonsoft.Json.Linq; using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Runtime.InteropServices; +using System.Text.Json; using System.Threading; namespace GVFS.FunctionalTests.Tools @@ -152,12 +152,14 @@ public string GetObjectRoot(FileSystemRunner fileSystem) .ToArray(); objectRootEntries.Length.ShouldEqual(1, $"Should be only one entry for repo url: {this.RepoUrl} mapping file content: {mappingFileContents}"); objectRootEntries[0].Substring(0, 2).ShouldEqual("A ", $"Invalid mapping entry for repo: {objectRootEntries[0]}"); - JObject rootEntryJson = JObject.Parse(objectRootEntries[0].Substring(2)); - string objectRootFolder = rootEntryJson.GetValue("Value").ToString(); - objectRootFolder.ShouldNotBeNull(); - objectRootFolder.Length.ShouldBeAtLeast(1, $"Invalid object root folder: {objectRootFolder} for {this.RepoUrl} mapping file content: {mappingFileContents}"); + using (JsonDocument rootEntryJson = JsonDocument.Parse(objectRootEntries[0].Substring(2))) + { + string objectRootFolder = rootEntryJson.RootElement.GetProperty("Value").GetString(); + objectRootFolder.ShouldNotBeNull(); + objectRootFolder.Length.ShouldBeAtLeast(1, $"Invalid object root folder: {objectRootFolder} for {this.RepoUrl} mapping file content: {mappingFileContents}"); - return Path.Combine(this.LocalCacheRoot, objectRootFolder, "gitObjects"); + return Path.Combine(this.LocalCacheRoot, objectRootFolder, "gitObjects"); + } } public string GetPackRoot(FileSystemRunner fileSystem) @@ -173,7 +175,11 @@ public void DeleteEnlistment() public void CloneAndMount(bool skipPrefetch) { + Console.Error.WriteLine("[CI-DEBUG] CloneAndMount: starting clone of " + this.RepoUrl); + Console.Error.Flush(); this.gvfsProcess.Clone(this.RepoUrl, this.Commitish, skipPrefetch); + Console.Error.WriteLine("[CI-DEBUG] CloneAndMount: clone complete, running git checkout"); + Console.Error.Flush(); GitProcess.Invoke(this.RepoRoot, "checkout " + this.Commitish); GitProcess.Invoke(this.RepoRoot, "branch --unset-upstream"); diff --git a/GVFS/GVFS.FunctionalTests/Tools/GVFSHelpers.cs b/GVFS/GVFS.FunctionalTests/Tools/GVFSHelpers.cs index d079e3668..d943035fb 100644 --- a/GVFS/GVFS.FunctionalTests/Tools/GVFSHelpers.cs +++ b/GVFS/GVFS.FunctionalTests/Tools/GVFSHelpers.cs @@ -1,8 +1,8 @@ -using GVFS.FunctionalTests.FileSystemRunners; +using GVFS.Common; +using GVFS.FunctionalTests.FileSystemRunners; using GVFS.FunctionalTests.Should; using GVFS.Tests.Should; using Microsoft.Data.Sqlite; -using Newtonsoft.Json; using NUnit.Framework; using System; using System.Collections.Generic; @@ -36,7 +36,7 @@ public static class GVFSHelpers private const int WindowsCurrentDiskLayoutMajorVersion = 19; private const int MacCurrentDiskLayoutMajorVersion = 19; - private const int WindowsCurrentDiskLayoutMinimumMajorVersion = 7; + private const int WindowsCurrentDiskLayoutMinimumMajorVersion = 14; private const int MacCurrentDiskLayoutMinimumMajorVersion = 18; public static string ConvertPathToGitFormat(string path) @@ -289,7 +289,7 @@ private static string GetPersistedValue(string dotGVFSRoot, string key) json = reader.ReadLine(); json.Substring(0, 2).ShouldEqual("A "); - KeyValuePair kvp = JsonConvert.DeserializeObject>(json.Substring(2)); + KeyValuePair kvp = GVFSJsonOptions.Deserialize>(json.Substring(2)); if (kvp.Key == key) { return kvp.Value; @@ -314,7 +314,7 @@ private static void SavePersistedValue(string dotGVFSRoot, string key, string va json = reader.ReadLine(); json.Substring(0, 2).ShouldEqual("A "); - KeyValuePair kvp = JsonConvert.DeserializeObject>(json.Substring(2)); + KeyValuePair kvp = GVFSJsonOptions.Deserialize>(json.Substring(2)); repoMetadata.Add(kvp.Key, kvp.Value); } } @@ -325,7 +325,7 @@ private static void SavePersistedValue(string dotGVFSRoot, string key, string va foreach (KeyValuePair kvp in repoMetadata) { - newRepoMetadataContents += "A " + JsonConvert.SerializeObject(kvp).Trim() + "\r\n"; + newRepoMetadataContents += "A " + GVFSJsonOptions.Serialize(kvp).Trim() + "\r\n"; } File.WriteAllText(metadataPath, newRepoMetadataContents); diff --git a/GVFS/GVFS.FunctionalTests/Tools/GVFSProcess.cs b/GVFS/GVFS.FunctionalTests/Tools/GVFSProcess.cs index a409d5762..5d7f415d7 100644 --- a/GVFS/GVFS.FunctionalTests/Tools/GVFSProcess.cs +++ b/GVFS/GVFS.FunctionalTests/Tools/GVFSProcess.cs @@ -254,6 +254,7 @@ private string CallGVFS(string args, int expectedExitCode = DoNotCheckExitCode, processInfo.WindowStyle = ProcessWindowStyle.Hidden; processInfo.UseShellExecute = false; processInfo.RedirectStandardOutput = true; + processInfo.RedirectStandardError = true; if (standardInput != null) { processInfo.RedirectStandardInput = true; @@ -264,6 +265,9 @@ private string CallGVFS(string args, int expectedExitCode = DoNotCheckExitCode, processInfo.EnvironmentVariables["GIT_TRACE"] = trace; } + Console.Error.WriteLine($"[CI-DEBUG] CallGVFS: {this.pathToGVFS} {processInfo.Arguments}"); + Console.Error.Flush(); + using (Process process = Process.Start(processInfo)) { if (standardInput != null) @@ -272,9 +276,49 @@ private string CallGVFS(string args, int expectedExitCode = DoNotCheckExitCode, process.StandardInput.Close(); } - string result = process.StandardOutput.ReadToEnd(); + // Stream stderr to console in real-time + process.ErrorDataReceived += (sender, e) => + { + if (e.Data != null) + { + Console.Error.WriteLine($"[gvfs stderr] {e.Data}"); + Console.Error.Flush(); + } + }; + process.BeginErrorReadLine(); + + // Stream stdout to console and capture it + System.Text.StringBuilder outputBuilder = new System.Text.StringBuilder(); + process.OutputDataReceived += (sender, e) => + { + if (e.Data != null) + { + outputBuilder.AppendLine(e.Data); + Console.Error.WriteLine($"[gvfs stdout] {e.Data}"); + Console.Error.Flush(); + } + }; + process.BeginOutputReadLine(); + + bool exited = process.WaitForExit(300000); // 5 minute timeout + if (!exited) + { + Console.Error.WriteLine("[CI-DEBUG] CallGVFS: TIMEOUT after 5 minutes, killing process"); + Console.Error.Flush(); + process.Kill(); + process.WaitForExit(5000); + throw new TimeoutException($"gvfs process timed out after 5 minutes. Args: {args}"); + } + + // The WaitForExit(timeout) overload does NOT wait for async + // output streams to finish reading. Call the parameterless + // overload to drain remaining stdout/stderr from the pipe. process.WaitForExit(); + string result = outputBuilder.ToString(); + Console.Error.WriteLine($"[CI-DEBUG] CallGVFS done: exit={process.ExitCode}"); + Console.Error.Flush(); + if (expectedExitCode >= SuccessExitCode) { process.ExitCode.ShouldEqual(expectedExitCode, result); diff --git a/GVFS/GVFS.FunctionalTests/Tools/GitProcess.cs b/GVFS/GVFS.FunctionalTests/Tools/GitProcess.cs index a054fe624..bc2135465 100644 --- a/GVFS/GVFS.FunctionalTests/Tools/GitProcess.cs +++ b/GVFS/GVFS.FunctionalTests/Tools/GitProcess.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; @@ -6,12 +7,20 @@ namespace GVFS.FunctionalTests.Tools { public static class GitProcess { + // Default: 5 minutes per git operation. Override with GVFS_FT_GIT_TIMEOUT_SECONDS. + public static int DefaultGitTimeoutMs { get; set; } = ReadGitTimeoutFromEnvironment(); + public static string Invoke(string executionWorkingDirectory, string command) { return InvokeProcess(executionWorkingDirectory, command).Output; } - public static ProcessResult InvokeProcess(string executionWorkingDirectory, string command, Dictionary environmentVariables = null, Stream inputStream = null) + public static ProcessResult InvokeProcess( + string executionWorkingDirectory, + string command, + Dictionary environmentVariables = null, + Stream inputStream = null, + int timeoutMs = -1) { ProcessStartInfo processInfo = new ProcessStartInfo(Properties.Settings.Default.PathToGit); processInfo.WorkingDirectory = executionWorkingDirectory; @@ -35,7 +44,20 @@ public static ProcessResult InvokeProcess(string executionWorkingDirectory, stri } } - return ProcessHelper.Run(processInfo, inputStream: inputStream); + int effectiveTimeout = timeoutMs > 0 ? timeoutMs : DefaultGitTimeoutMs; + return ProcessHelper.Run(processInfo, inputStream: inputStream, timeoutMs: effectiveTimeout); + } + + private static int ReadGitTimeoutFromEnvironment() + { + string envValue = Environment.GetEnvironmentVariable("GVFS_FT_GIT_TIMEOUT_SECONDS"); + if (!string.IsNullOrEmpty(envValue) && int.TryParse(envValue, out int seconds) && seconds > 0) + { + return seconds * 1000; + } + + // Default: 5 minutes per git operation + return 300_000; } } } diff --git a/GVFS/GVFS.FunctionalTests/Tools/ProcessHelper.cs b/GVFS/GVFS.FunctionalTests/Tools/ProcessHelper.cs index 539c5cc82..a6edabc3f 100644 --- a/GVFS/GVFS.FunctionalTests/Tools/ProcessHelper.cs +++ b/GVFS/GVFS.FunctionalTests/Tools/ProcessHelper.cs @@ -1,16 +1,25 @@ -using System.Diagnostics; +using System; +using System.Diagnostics; using System.IO; +using System.Threading.Tasks; namespace GVFS.FunctionalTests.Tools { public static class ProcessHelper { + /// + /// Default timeout in milliseconds for child processes. -1 means infinite. + /// Set via GVFS_FT_PROCESS_TIMEOUT_SECONDS environment variable (applies to all + /// ProcessHelper.Run calls) or override per-call via the timeoutMs parameter. + /// + public static int DefaultTimeoutMs { get; set; } = ReadTimeoutFromEnvironment(); + public static ProcessResult Run(string fileName, string arguments) { - return Run(fileName, arguments, null); + return Run(fileName, arguments, workingDirectory: null); } - public static ProcessResult Run(string fileName, string arguments, string workingDirectory) + public static ProcessResult Run(string fileName, string arguments, string workingDirectory, int timeoutMs = -1) { ProcessStartInfo startInfo = new ProcessStartInfo(); startInfo.UseShellExecute = false; @@ -24,11 +33,18 @@ public static ProcessResult Run(string fileName, string arguments, string workin startInfo.WorkingDirectory = workingDirectory; } - return Run(startInfo); + return Run(startInfo, timeoutMs: timeoutMs); } - public static ProcessResult Run(ProcessStartInfo processInfo, string errorMsgDelimeter = "\r\n", object executionLock = null, Stream inputStream = null) + public static ProcessResult Run( + ProcessStartInfo processInfo, + string errorMsgDelimeter = "\r\n", + object executionLock = null, + Stream inputStream = null, + int timeoutMs = -1) { + int effectiveTimeout = timeoutMs > 0 ? timeoutMs : DefaultTimeoutMs; + using (Process executingProcess = new Process()) { string output = string.Empty; @@ -50,25 +66,27 @@ public static ProcessResult Run(ProcessStartInfo processInfo, string errorMsgDel { lock (executionLock) { - output = StartProcess(executingProcess, inputStream); + output = StartProcess(executingProcess, inputStream, effectiveTimeout); } } else { - output = StartProcess(executingProcess, inputStream); + output = StartProcess(executingProcess, inputStream, effectiveTimeout); } return new ProcessResult(output.ToString(), errors.ToString(), executingProcess.ExitCode); } } - private static string StartProcess(Process executingProcess, Stream inputStream = null) + private static string StartProcess(Process executingProcess, Stream inputStream, int timeoutMs) { + Stopwatch stopwatch = Stopwatch.StartNew(); executingProcess.Start(); if (inputStream != null) { inputStream.CopyTo(executingProcess.StandardInput.BaseStream); + executingProcess.StandardInput.Close(); } if (executingProcess.StartInfo.RedirectStandardError) @@ -79,12 +97,78 @@ private static string StartProcess(Process executingProcess, Stream inputStream string output = string.Empty; if (executingProcess.StartInfo.RedirectStandardOutput) { - output = executingProcess.StandardOutput.ReadToEnd(); + if (timeoutMs > 0) + { + // Read stdout asynchronously so we can enforce a timeout on the + // entire process lifecycle. Without this, ReadToEnd() blocks + // indefinitely if the child process hangs. + Task readTask = executingProcess.StandardOutput.ReadToEndAsync(); + if (!readTask.Wait(timeoutMs)) + { + KillProcessTree(executingProcess); + string processDesc = FormatProcessDescription(executingProcess); + throw new TimeoutException( + $"Process timed out after {timeoutMs / 1000}s: {processDesc}"); + } + + output = readTask.Result; + } + else + { + output = executingProcess.StandardOutput.ReadToEnd(); + } } executingProcess.WaitForExit(); + if (timeoutMs > 0) + { + stopwatch.Stop(); + long elapsedMs = stopwatch.ElapsedMilliseconds; + if (elapsedMs > 30_000) + { + // Log slow processes to help diagnose intermittent hangs + string processDesc = FormatProcessDescription(executingProcess); + Console.WriteLine( + $"[{DateTime.Now:HH:mm:ss.fff}] [SLOW-PROCESS] {processDesc} " + + $"completed in {elapsedMs / 1000.0:F1}s (timeout: {timeoutMs / 1000}s)"); + Console.Out.Flush(); + } + } + return output; } + + private static void KillProcessTree(Process process) + { + try + { + process.Kill(entireProcessTree: true); + } + catch (Exception ex) + { + Console.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] [WARN] Failed to kill process tree: {ex.Message}"); + Console.Out.Flush(); + } + } + + private static string FormatProcessDescription(Process process) + { + string fileName = process.StartInfo.FileName; + string args = process.StartInfo.Arguments; + string workDir = process.StartInfo.WorkingDirectory; + return $"'{fileName} {args}' (cwd: {workDir})"; + } + + private static int ReadTimeoutFromEnvironment() + { + string envValue = Environment.GetEnvironmentVariable("GVFS_FT_PROCESS_TIMEOUT_SECONDS"); + if (!string.IsNullOrEmpty(envValue) && int.TryParse(envValue, out int seconds) && seconds > 0) + { + return seconds * 1000; + } + + return -1; + } } } diff --git a/GVFS/GVFS.FunctionalTests/Windows/Tests/SharedCacheUpgradeTests.cs b/GVFS/GVFS.FunctionalTests/Windows/Tests/SharedCacheUpgradeTests.cs index 3025b443e..e6432ed41 100644 --- a/GVFS/GVFS.FunctionalTests/Windows/Tests/SharedCacheUpgradeTests.cs +++ b/GVFS/GVFS.FunctionalTests/Windows/Tests/SharedCacheUpgradeTests.cs @@ -3,11 +3,9 @@ using GVFS.FunctionalTests.Tests.MultiEnlistmentTests; using GVFS.FunctionalTests.Tools; using GVFS.FunctionalTests.Windows.Tests; -using GVFS.FunctionalTests.Windows.Tools; using GVFS.Tests.Should; using NUnit.Framework; using System; -using System.Collections.Generic; using System.IO; namespace GVFS.FunctionalTests.Windows.Windows.Tests @@ -33,115 +31,6 @@ public void SetCacheLocation() this.localCachePath = Path.Combine(this.localCacheParentPath, ".customGVFSCache"); } - [TestCase] - public void MountUpgradesLocalSizesToSharedCache() - { - GVFSFunctionalTestEnlistment enlistment = this.CloneAndMountEnlistment(); - enlistment.UnmountGVFS(); - - string localCacheRoot = GVFSHelpers.GetPersistedLocalCacheRoot(enlistment.DotGVFSRoot); - string gitObjectsRoot = GVFSHelpers.GetPersistedGitObjectsRoot(enlistment.DotGVFSRoot); - - // Delete the existing repo metadata - string versionJsonPath = Path.Combine(enlistment.DotGVFSRoot, GVFSHelpers.RepoMetadataName); - versionJsonPath.ShouldBeAFile(this.fileSystem); - this.fileSystem.DeleteFile(versionJsonPath); - - // Since there isn't a sparse-checkout file that is used anymore one needs to be added - // in order to test the old upgrades that might have needed it - string sparseCheckoutPath = Path.Combine(enlistment.RepoRoot, TestConstants.DotGit.Info.SparseCheckoutPath); - this.fileSystem.WriteAllText(sparseCheckoutPath, "/.gitattributes\r\n"); - - // "13.0" was the last version before blob sizes were moved out of Esent - string metadataPath = Path.Combine(enlistment.DotGVFSRoot, GVFSHelpers.RepoMetadataName); - this.fileSystem.CreateEmptyFile(metadataPath); - GVFSHelpers.SaveDiskLayoutVersion(enlistment.DotGVFSRoot, "13", "0"); - GVFSHelpers.SaveLocalCacheRoot(enlistment.DotGVFSRoot, localCacheRoot); - GVFSHelpers.SaveGitObjectsRoot(enlistment.DotGVFSRoot, gitObjectsRoot); - - // Create a legacy PersistedDictionary sizes database - List> entries = new List>() - { - new KeyValuePair(new string('0', 40), 1), - new KeyValuePair(new string('1', 40), 2), - new KeyValuePair(new string('2', 40), 4), - new KeyValuePair(new string('3', 40), 8), - }; - - ESENTDatabase.CreateEsentBlobSizesDatabase(enlistment.DotGVFSRoot, entries); - - enlistment.MountGVFS(); - - string majorVersion; - string minorVersion; - GVFSHelpers.GetPersistedDiskLayoutVersion(enlistment.DotGVFSRoot, out majorVersion, out minorVersion); - - majorVersion - .ShouldBeAnInt("Disk layout version should always be an int") - .ShouldEqual(WindowsDiskLayoutUpgradeTests.CurrentDiskLayoutMajorVersion, "Disk layout version should be upgraded to the latest"); - - minorVersion - .ShouldBeAnInt("Disk layout version should always be an int") - .ShouldEqual(WindowsDiskLayoutUpgradeTests.CurrentDiskLayoutMinorVersion, "Disk layout version should be upgraded to the latest"); - - string newBlobSizesRoot = Path.Combine(Path.GetDirectoryName(gitObjectsRoot), WindowsDiskLayoutUpgradeTests.BlobSizesCacheName); - GVFSHelpers.GetPersistedBlobSizesRoot(enlistment.DotGVFSRoot) - .ShouldEqual(newBlobSizesRoot); - - string blobSizesDbPath = Path.Combine(newBlobSizesRoot, WindowsDiskLayoutUpgradeTests.BlobSizesDBFileName); - newBlobSizesRoot.ShouldBeADirectory(this.fileSystem); - blobSizesDbPath.ShouldBeAFile(this.fileSystem); - - foreach (KeyValuePair entry in entries) - { - GVFSHelpers.SQLiteBlobSizesDatabaseHasEntry(blobSizesDbPath, entry.Key, entry.Value); - } - - // Upgrade a second repo, and make sure all sizes from both upgrades are in the shared database - - GVFSFunctionalTestEnlistment enlistment2 = this.CloneAndMountEnlistment(); - enlistment2.UnmountGVFS(); - - // Delete the existing repo metadata - versionJsonPath = Path.Combine(enlistment2.DotGVFSRoot, GVFSHelpers.RepoMetadataName); - versionJsonPath.ShouldBeAFile(this.fileSystem); - this.fileSystem.DeleteFile(versionJsonPath); - - // Since there isn't a sparse-checkout file that is used anymore one needs to be added - // in order to test the old upgrades that might have needed it - string sparseCheckoutPath2 = Path.Combine(enlistment2.RepoRoot, TestConstants.DotGit.Info.SparseCheckoutPath); - this.fileSystem.WriteAllText(sparseCheckoutPath2, "/.gitattributes\r\n"); - - // "13.0" was the last version before blob sizes were moved out of Esent - metadataPath = Path.Combine(enlistment2.DotGVFSRoot, GVFSHelpers.RepoMetadataName); - this.fileSystem.CreateEmptyFile(metadataPath); - GVFSHelpers.SaveDiskLayoutVersion(enlistment2.DotGVFSRoot, "13", "0"); - GVFSHelpers.SaveLocalCacheRoot(enlistment2.DotGVFSRoot, localCacheRoot); - GVFSHelpers.SaveGitObjectsRoot(enlistment2.DotGVFSRoot, gitObjectsRoot); - - // Create a legacy PersistedDictionary sizes database - List> additionalEntries = new List>() - { - new KeyValuePair(new string('4', 40), 16), - new KeyValuePair(new string('5', 40), 32), - new KeyValuePair(new string('6', 40), 64), - }; - - ESENTDatabase.CreateEsentBlobSizesDatabase(enlistment2.DotGVFSRoot, additionalEntries); - - enlistment2.MountGVFS(); - - foreach (KeyValuePair entry in entries) - { - GVFSHelpers.SQLiteBlobSizesDatabaseHasEntry(blobSizesDbPath, entry.Key, entry.Value); - } - - foreach (KeyValuePair entry in additionalEntries) - { - GVFSHelpers.SQLiteBlobSizesDatabaseHasEntry(blobSizesDbPath, entry.Key, entry.Value); - } - } - private GVFSFunctionalTestEnlistment CloneAndMountEnlistment(string branch = null) { return this.CreateNewEnlistment(this.localCachePath, branch); diff --git a/GVFS/GVFS.FunctionalTests/Windows/Tests/WindowsDiskLayoutUpgradeTests.cs b/GVFS/GVFS.FunctionalTests/Windows/Tests/WindowsDiskLayoutUpgradeTests.cs index 328652458..a790516b6 100644 --- a/GVFS/GVFS.FunctionalTests/Windows/Tests/WindowsDiskLayoutUpgradeTests.cs +++ b/GVFS/GVFS.FunctionalTests/Windows/Tests/WindowsDiskLayoutUpgradeTests.cs @@ -1,7 +1,6 @@ using GVFS.FunctionalTests.Should; using GVFS.FunctionalTests.Tests.EnlistmentPerTestCase; using GVFS.FunctionalTests.Tools; -using GVFS.FunctionalTests.Windows.Tools; using GVFS.Tests.Should; using NUnit.Framework; using System; @@ -38,106 +37,15 @@ public override void CreateEnlistment() } [TestCase] - public void MountUpgradesFromVersion7() - { - // Seven to eight is a just a version change (non-breaking), but preserves ESENT RepoMetadata - this.RunEsentRepoMetadataUpgradeTest("7"); - } - - [TestCase] - public void MountUpgradesFromEsentToJsonRepoMetadata() - { - // Eight is the last version with ESENT RepoMetadata DB - this.RunEsentRepoMetadataUpgradeTest("8"); - } - - [TestCase] - public void MountUpgradesFromEsentDatabasesToFlatDatabases() - { - this.Enlistment.UnmountGVFS(); - - // Delete the existing background ops data - string flatBackgroundPath = Path.Combine(this.Enlistment.DotGVFSRoot, GVFSHelpers.BackgroundOpsFile); - flatBackgroundPath.ShouldBeAFile(this.fileSystem); - this.fileSystem.DeleteFile(flatBackgroundPath); - - // Delete the existing placeholder data - string placeholdersPath = Path.Combine(this.Enlistment.DotGVFSRoot, TestConstants.Databases.VFSForGit); - placeholdersPath.ShouldBeAFile(this.fileSystem); - this.fileSystem.DeleteFile(placeholdersPath); - - ESENTDatabase.CreateEsentBackgroundOpsDatabase(this.Enlistment.DotGVFSRoot); - ESENTDatabase.CreateEsentPlaceholderDatabase(this.Enlistment.DotGVFSRoot); - - // Nine is the last version with ESENT BackgroundOps and Placeholders DBs - GVFSHelpers.SaveDiskLayoutVersion(this.Enlistment.DotGVFSRoot, "9", "0"); - this.Enlistment.MountGVFS(); - - this.ValidatePersistedVersionMatchesCurrentVersion(); - - flatBackgroundPath.ShouldBeAFile(this.fileSystem); - placeholdersPath.ShouldBeAFile(this.fileSystem); - } - - [TestCase] - public void MountUpgradesFromPriorToPlaceholderCreationsBlockedForGit() - { - this.Enlistment.UnmountGVFS(); - - GVFSHelpers.SaveDiskLayoutVersion(this.Enlistment.DotGVFSRoot, "10", "0"); - - this.Enlistment.MountGVFS(); - - this.ValidatePersistedVersionMatchesCurrentVersion(); - } - - [TestCase] - public void MountFailsToUpgradeFromEsentVersion6ToJsonRepoMetadata() - { - this.Enlistment.UnmountGVFS(); - - // Delete the existing repo metadata - string versionJsonPath = Path.Combine(this.Enlistment.DotGVFSRoot, GVFSHelpers.RepoMetadataName); - versionJsonPath.ShouldBeAFile(this.fileSystem); - this.fileSystem.DeleteFile(versionJsonPath); - - ESENTDatabase.SaveDiskLayoutVersionAsEsentDatabase(this.Enlistment.DotGVFSRoot, "6"); - string esentDatabasePath = Path.Combine(this.Enlistment.DotGVFSRoot, ESENTDatabase.EsentRepoMetadataFolder); - esentDatabasePath.ShouldBeADirectory(this.fileSystem); - - this.Enlistment.TryMountGVFS().ShouldEqual(false, "Should not be able to upgrade from version 6"); - - esentDatabasePath.ShouldBeADirectory(this.fileSystem); - } - - [TestCase] - public void MountSetsGitObjectsRootToLegacyDotGVFSCache() + public void MountUpgradesFromMinimumSupportedVersion() { this.Enlistment.UnmountGVFS(); - // Delete the existing repo metadata - string versionJsonPath = Path.Combine(this.Enlistment.DotGVFSRoot, GVFSHelpers.RepoMetadataName); - versionJsonPath.ShouldBeAFile(this.fileSystem); - this.fileSystem.DeleteFile(versionJsonPath); - - // "11" was the last version before the introduction of a volume wide GVFS cache - string metadataPath = Path.Combine(this.Enlistment.DotGVFSRoot, GVFSHelpers.RepoMetadataName); - this.fileSystem.CreateEmptyFile(metadataPath); - GVFSHelpers.SaveDiskLayoutVersion(this.Enlistment.DotGVFSRoot, "11", "0"); - - // Create the legacy cache location: \.gvfs\gitObjectCache - string legacyGitObjectsCachePath = Path.Combine(this.Enlistment.DotGVFSRoot, "gitObjectCache"); - this.fileSystem.CreateDirectory(legacyGitObjectsCachePath); + GVFSHelpers.SaveDiskLayoutVersion(this.Enlistment.DotGVFSRoot, "14", "0"); this.Enlistment.MountGVFS(); this.ValidatePersistedVersionMatchesCurrentVersion(); - - GVFSHelpers.GetPersistedLocalCacheRoot(this.Enlistment.DotGVFSRoot) - .ShouldEqual(string.Empty, "LocalCacheRoot should be an empty string when upgrading from a version prior to 12"); - - GVFSHelpers.GetPersistedGitObjectsRoot(this.Enlistment.DotGVFSRoot) - .ShouldEqual(legacyGitObjectsCachePath); } [TestCase] @@ -159,7 +67,7 @@ public void MountWritesFolderPlaceholdersToPlaceholderDatabase() placeholderDatabasePath, string.Join(Environment.NewLine, lines.Where(x => !x.EndsWith(TestConstants.PartialFolderPlaceholderDatabaseValue))) + Environment.NewLine); - GVFSHelpers.SaveDiskLayoutVersion(this.Enlistment.DotGVFSRoot, "12", "1"); + GVFSHelpers.SaveDiskLayoutVersion(this.Enlistment.DotGVFSRoot, "15", "0"); this.Enlistment.MountGVFS(); this.Enlistment.UnmountGVFS(); @@ -200,65 +108,11 @@ public void MountUpdatesAllZeroShaFolderPlaceholderEntriesToPartialFolderSpecial this.ValidatePersistedVersionMatchesCurrentVersion(); } - [TestCase] - public void MountUpgradesPreSharedCacheLocalSizes() - { - this.Enlistment.UnmountGVFS(); - - // Delete the existing repo metadata - string versionJsonPath = Path.Combine(this.Enlistment.DotGVFSRoot, GVFSHelpers.RepoMetadataName); - versionJsonPath.ShouldBeAFile(this.fileSystem); - this.fileSystem.DeleteFile(versionJsonPath); - - // "11" was the last version before the introduction of a volume wide GVFS cache - string metadataPath = Path.Combine(this.Enlistment.DotGVFSRoot, GVFSHelpers.RepoMetadataName); - this.fileSystem.CreateEmptyFile(metadataPath); - GVFSHelpers.SaveDiskLayoutVersion(this.Enlistment.DotGVFSRoot, "11", "0"); - - // Create the legacy cache location: \.gvfs\gitObjectCache - string legacyGitObjectsCachePath = Path.Combine(this.Enlistment.DotGVFSRoot, "gitObjectCache"); - this.fileSystem.CreateDirectory(legacyGitObjectsCachePath); - - // Create a legacy PersistedDictionary sizes database - List> entries = new List>() - { - new KeyValuePair(new string('0', 40), 1), - new KeyValuePair(new string('1', 40), 2), - new KeyValuePair(new string('2', 40), 4), - new KeyValuePair(new string('3', 40), 8), - }; - - ESENTDatabase.CreateEsentBlobSizesDatabase(this.Enlistment.DotGVFSRoot, entries); - - this.Enlistment.MountGVFS(); - - this.ValidatePersistedVersionMatchesCurrentVersion(); - - GVFSHelpers.GetPersistedLocalCacheRoot(this.Enlistment.DotGVFSRoot) - .ShouldEqual(string.Empty, "LocalCacheRoot should be an empty string when upgrading from a version prior to 12"); - - GVFSHelpers.GetPersistedGitObjectsRoot(this.Enlistment.DotGVFSRoot) - .ShouldEqual(legacyGitObjectsCachePath); - - string newBlobSizesRoot = Path.Combine(this.Enlistment.DotGVFSRoot, DatabasesFolderName, BlobSizesCacheName); - GVFSHelpers.GetPersistedBlobSizesRoot(this.Enlistment.DotGVFSRoot) - .ShouldEqual(newBlobSizesRoot); - - string blobSizesDbPath = Path.Combine(newBlobSizesRoot, BlobSizesDBFileName); - newBlobSizesRoot.ShouldBeADirectory(this.fileSystem); - blobSizesDbPath.ShouldBeAFile(this.fileSystem); - - foreach (KeyValuePair entry in entries) - { - GVFSHelpers.SQLiteBlobSizesDatabaseHasEntry(blobSizesDbPath, entry.Key, entry.Value); - } - } - [TestCase] public void MountCreatesModifiedPathsDatabase() { this.Enlistment.UnmountGVFS(); - GVFSHelpers.SaveDiskLayoutVersion(this.Enlistment.DotGVFSRoot, "14", "0"); + GVFSHelpers.SaveDiskLayoutVersion(this.Enlistment.DotGVFSRoot, "15", "0"); // Delete the existing modified paths database to make sure mount creates it. string modifiedPathsDatabasePath = Path.Combine(this.Enlistment.DotGVFSRoot, TestConstants.Databases.ModifiedPaths); @@ -376,37 +230,5 @@ private string[] GetPlaceholderDatabaseLinesAfterUpgradeFrom16(string placeholde lines.ShouldContain(x => x == this.PartialFolderPlaceholderString("GVFS", "GVFS.Tests", "Properties")); return lines; } - - private void RunEsentRepoMetadataUpgradeTest(string sourceVersion) - { - this.Enlistment.UnmountGVFS(); - - // Delete the existing repo metadata - string versionJsonPath = Path.Combine(this.Enlistment.DotGVFSRoot, GVFSHelpers.RepoMetadataName); - versionJsonPath.ShouldBeAFile(this.fileSystem); - this.fileSystem.DeleteFile(versionJsonPath); - - ESENTDatabase.SaveDiskLayoutVersionAsEsentDatabase(this.Enlistment.DotGVFSRoot, sourceVersion); - string esentDatabasePath = Path.Combine(this.Enlistment.DotGVFSRoot, ESENTDatabase.EsentRepoMetadataFolder); - esentDatabasePath.ShouldBeADirectory(this.fileSystem); - - // We should be able to mount, and there should no longer be any Esent Repo Metadata - this.Enlistment.MountGVFS(); - esentDatabasePath.ShouldNotExistOnDisk(this.fileSystem); - versionJsonPath.ShouldBeAFile(this.fileSystem); - - this.ValidatePersistedVersionMatchesCurrentVersion(); - - GVFSHelpers.GetPersistedLocalCacheRoot(this.Enlistment.DotGVFSRoot) - .ShouldEqual(string.Empty, "LocalCacheRoot should be an empty string when upgrading from a version prior to 12"); - - // We're starting with fresh enlisments, and so the legacy cache location: \.gvfs\gitObjectCache should not be on disk - Path.Combine(this.Enlistment.DotGVFSRoot, ".gvfs", "gitObjectCache").ShouldNotExistOnDisk(this.fileSystem); - - // The upgrader should set GitObjectsRoot to src\.git\objects (because the legacy cache location is not on disk) - GVFSHelpers.GetPersistedGitObjectsRoot(this.Enlistment.DotGVFSRoot) - .ShouldNotBeNull("GitObjectsRoot should not be null") - .ShouldEqual(Path.Combine(this.Enlistment.RepoRoot, ".git", "objects")); - } } } diff --git a/GVFS/GVFS.FunctionalTests/Windows/Tests/WindowsTombstoneTests.cs b/GVFS/GVFS.FunctionalTests/Windows/Tests/WindowsTombstoneTests.cs index f2c1b2d47..93c2ac5c2 100644 --- a/GVFS/GVFS.FunctionalTests/Windows/Tests/WindowsTombstoneTests.cs +++ b/GVFS/GVFS.FunctionalTests/Windows/Tests/WindowsTombstoneTests.cs @@ -4,8 +4,10 @@ using GVFS.Tests.Should; using NUnit.Framework; using System; +using System.Diagnostics; using System.IO; using System.Linq; +using System.Threading; namespace GVFS.FunctionalTests.Tests.EnlistmentPerFixture { @@ -15,6 +17,8 @@ public class WindowsTombstoneTests : TestsWithEnlistmentPerFixture { private const string Delimiter = "\r\n"; private const int TombstoneFolderPlaceholderType = 3; + private const int MaxFileAccessRetries = 10; + private const int FileAccessRetryDelayMs = 500; private FileSystemRunner fileSystem; public WindowsTombstoneTests() @@ -30,30 +34,162 @@ public void CheckoutCleansUpTombstones() // Delete directory to create the tombstone string directoryToDelete = this.Enlistment.GetVirtualPathTo(folderToDelete); this.fileSystem.DeleteDirectory(directoryToDelete); + + DiagLog("Unmounting GVFS (first unmount)..."); + Stopwatch sw = Stopwatch.StartNew(); this.Enlistment.UnmountGVFS(); + sw.Stop(); + DiagLog($"Unmount completed in {sw.ElapsedMilliseconds}ms"); // Remove the directory entry from modified paths so git will not keep the folder up to date string modifiedPathsFile = Path.Combine(this.Enlistment.DotGVFSRoot, TestConstants.Databases.ModifiedPaths); - string modifiedPathsContent = this.fileSystem.ReadAllText(modifiedPathsFile); + + DiagLog($"ModifiedPaths path: {modifiedPathsFile}"); + DiagLog($"ModifiedPaths exists: {File.Exists(modifiedPathsFile)}"); + if (File.Exists(modifiedPathsFile)) + { + FileInfo fi = new FileInfo(modifiedPathsFile); + DiagLog($"ModifiedPaths size: {fi.Length} bytes, lastWrite: {fi.LastWriteTimeUtc:O}"); + } + + string modifiedPathsContent = ReadFileWithRetry(modifiedPathsFile); + DiagLog($"ModifiedPaths read OK, length: {modifiedPathsContent.Length} chars, lines: {modifiedPathsContent.Split(new[] { Delimiter }, StringSplitOptions.RemoveEmptyEntries).Length}"); + modifiedPathsContent = string.Join(Delimiter, modifiedPathsContent.Split(new[] { Delimiter }, StringSplitOptions.RemoveEmptyEntries).Where(x => !x.StartsWith($"A {folderToDelete}/"))); - this.fileSystem.WriteAllText(modifiedPathsFile, modifiedPathsContent + Delimiter); + string contentToWrite = modifiedPathsContent + Delimiter; + DiagLog($"ModifiedPaths writing {contentToWrite.Length} chars..."); + WriteFileWithRetry(modifiedPathsFile, contentToWrite); + DiagLog("ModifiedPaths write OK"); + + // Verify file was written correctly + string verifyContent = ReadFileWithRetry(modifiedPathsFile); + DiagLog($"ModifiedPaths verify read: {verifyContent.Length} chars, match: {verifyContent == contentToWrite}"); // Add tombstone folder entry to the placeholder database so the checkout will remove the tombstone // and start projecting the folder again string placeholderDatabasePath = Path.Combine(this.Enlistment.DotGVFSRoot, TestConstants.Databases.VFSForGit); + DiagLog($"Placeholder DB path: {placeholderDatabasePath}, exists: {File.Exists(placeholderDatabasePath)}"); GVFSHelpers.AddPlaceholderFolder(placeholderDatabasePath, folderToDelete, TombstoneFolderPlaceholderType); + DiagLog("Placeholder folder entry added"); + + DiagLog("Mounting GVFS (after ModifiedPaths edit)..."); + sw.Restart(); + + string mountOutput; + bool mountSucceeded = this.Enlistment.TryMountGVFS(out mountOutput); + sw.Stop(); + DiagLog($"Mount returned in {sw.ElapsedMilliseconds}ms, success: {mountSucceeded}"); + if (!mountSucceeded) + { + // Dump diagnostics before failing + DiagLog($"Mount output: {mountOutput}"); + DiagLog($"ModifiedPaths after failed mount exists: {File.Exists(modifiedPathsFile)}"); + if (File.Exists(modifiedPathsFile)) + { + try + { + string postMountContent = File.ReadAllText(modifiedPathsFile); + DiagLog($"ModifiedPaths content after failed mount ({postMountContent.Length} chars):"); + DiagLog(postMountContent); + } + catch (Exception ex) + { + DiagLog($"Could not read ModifiedPaths after failed mount: {ex.GetType().Name}: {ex.Message}"); + } + } + + // Dump GVFS logs + string gvfsLogsDir = Path.Combine(this.Enlistment.DotGVFSRoot, "logs"); + if (Directory.Exists(gvfsLogsDir)) + { + string[] logFiles = Directory.GetFiles(gvfsLogsDir, "*.log", SearchOption.TopDirectoryOnly); + DiagLog($"GVFS log files ({logFiles.Length}):"); + foreach (string logFile in logFiles) + { + DiagLog($" {Path.GetFileName(logFile)}"); + } + + // Dump tail of most recent mount log + string[] mountLogs = Directory.GetFiles(gvfsLogsDir, "mount_*", SearchOption.TopDirectoryOnly); + if (mountLogs.Length > 0) + { + string latestMountLog = mountLogs.OrderByDescending(f => new FileInfo(f).LastWriteTimeUtc).First(); + try + { + string[] mountLogLines = File.ReadAllLines(latestMountLog); + int tailCount = Math.Min(50, mountLogLines.Length); + DiagLog($"Last {tailCount} lines of {Path.GetFileName(latestMountLog)}:"); + foreach (string line in mountLogLines.Skip(mountLogLines.Length - tailCount)) + { + DiagLog($" {line}"); + } + } + catch (Exception ex) + { + DiagLog($"Could not read mount log: {ex.GetType().Name}: {ex.Message}"); + } + } + } + + Assert.Fail($"GVFS did not mount: {mountOutput}"); + } - this.Enlistment.MountGVFS(); directoryToDelete.ShouldNotExistOnDisk(this.fileSystem); // checkout branch to remove tombstones and project the folder again GitHelpers.InvokeGitAgainstGVFSRepo(this.Enlistment.RepoRoot, "checkout -f HEAD"); directoryToDelete.ShouldBeADirectory(this.fileSystem); + DiagLog("Unmounting GVFS (final unmount)..."); this.Enlistment.UnmountGVFS(); + DiagLog("Final unmount completed"); string placholders = GVFSHelpers.GetAllSQLitePlaceholdersAsString(placeholderDatabasePath); placholders.ShouldNotContain(ignoreCase: false, unexpectedSubstrings: $"{folderToDelete}{GVFSHelpers.PlaceholderFieldDelimiter}{TombstoneFolderPlaceholderType}{GVFSHelpers.PlaceholderFieldDelimiter}"); } + + private static void DiagLog(string message) + { + Console.Error.WriteLine($"[TOMBSTONE-DIAG] {DateTime.UtcNow:O} {message}"); + } + + private static string ReadFileWithRetry(string path) + { + for (int attempt = 1; attempt <= MaxFileAccessRetries; attempt++) + { + try + { + return File.ReadAllText(path); + } + catch (IOException ex) when (attempt < MaxFileAccessRetries) + { + DiagLog($"ReadFile attempt {attempt}/{MaxFileAccessRetries} failed: {ex.GetType().Name}: {ex.Message}"); + Thread.Sleep(FileAccessRetryDelayMs); + } + } + + // Final attempt — let it throw + return File.ReadAllText(path); + } + + private static void WriteFileWithRetry(string path, string content) + { + for (int attempt = 1; attempt <= MaxFileAccessRetries; attempt++) + { + try + { + File.WriteAllText(path, content); + return; + } + catch (IOException ex) when (attempt < MaxFileAccessRetries) + { + DiagLog($"WriteFile attempt {attempt}/{MaxFileAccessRetries} failed: {ex.GetType().Name}: {ex.Message}"); + Thread.Sleep(FileAccessRetryDelayMs); + } + } + + // Final attempt — let it throw + File.WriteAllText(path, content); + } } } diff --git a/GVFS/GVFS.FunctionalTests/Windows/Tools/ESENTDatabase.cs b/GVFS/GVFS.FunctionalTests/Windows/Tools/ESENTDatabase.cs deleted file mode 100644 index 31c04e0b5..000000000 --- a/GVFS/GVFS.FunctionalTests/Windows/Tools/ESENTDatabase.cs +++ /dev/null @@ -1,71 +0,0 @@ -using System.Collections.Generic; -using System.IO; -using System.Reflection; -using Microsoft.Isam.Esent.Collections.Generic; - -namespace GVFS.FunctionalTests.Windows.Tools -{ - public static class ESENTDatabase - { - public const string EsentRepoMetadataFolder = "RepoMetadata"; - public const string EsentBackgroundOpsFolder = "BackgroundGitUpdates"; - public const string EsentBlobSizesFolder = "BlobSizes"; - public const string EsentPlaceholderFolder = "PlaceholderList"; - - private const string DiskLayoutMajorVersionKey = "DiskLayoutVersion"; - - public static void SaveDiskLayoutVersionAsEsentDatabase(string dotGVFSRoot, string majorVersion) - { - string metadataPath = Path.Combine(dotGVFSRoot, EsentRepoMetadataFolder); - using (PersistentDictionary repoMetadata = new PersistentDictionary(metadataPath)) - { - repoMetadata[DiskLayoutMajorVersionKey] = majorVersion; - repoMetadata.Flush(); - } - } - - public static void CreateEsentPlaceholderDatabase(string dotGVFSRoot) - { - string metadataPath = Path.Combine(dotGVFSRoot, EsentPlaceholderFolder); - using (PersistentDictionary placeholders = new PersistentDictionary(metadataPath)) - { - placeholders["mock:\\path"] = new string('0', 40); - placeholders.Flush(); - } - } - - public static void CreateEsentBackgroundOpsDatabase(string dotGVFSRoot) - { - // Copies an ESENT DB with a single entry: - // Operation=6 (OnFirstWrite) Path=.gitattributes VirtualPath=.gitattributes Id=1 - string testDataPath = GetTestDataPath(EsentBackgroundOpsFolder); - string metadataPath = Path.Combine(dotGVFSRoot, EsentBackgroundOpsFolder); - Directory.CreateDirectory(metadataPath); - foreach (string filepath in Directory.EnumerateFiles(testDataPath)) - { - string filename = Path.GetFileName(filepath); - File.Copy(filepath, Path.Combine(metadataPath, filename)); - } - } - - public static void CreateEsentBlobSizesDatabase(string dotGVFSRoot, List> entries) - { - string metadataPath = Path.Combine(dotGVFSRoot, EsentBlobSizesFolder); - using (PersistentDictionary blobSizes = new PersistentDictionary(metadataPath)) - { - foreach (KeyValuePair entry in entries) - { - blobSizes[entry.Key] = entry.Value; - } - - blobSizes.Flush(); - } - } - - private static string GetTestDataPath(string fileName) - { - string workingDirectory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); - return Path.Combine(workingDirectory, "Windows", "TestData", fileName); - } - } -} diff --git a/GVFS/GVFS.GVFlt/GVFS.GVFlt.csproj b/GVFS/GVFS.GVFlt/GVFS.GVFlt.csproj deleted file mode 100644 index 97e2973e3..000000000 --- a/GVFS/GVFS.GVFlt/GVFS.GVFlt.csproj +++ /dev/null @@ -1,11 +0,0 @@ - - - - net471 - - - - - - - diff --git a/GVFS/GVFS.GVFlt/GVFltCallbacks.cs b/GVFS/GVFS.GVFlt/GVFltCallbacks.cs deleted file mode 100644 index f841f12ec..000000000 --- a/GVFS/GVFS.GVFlt/GVFltCallbacks.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Newtonsoft.Json; -using System; - -namespace GVFS.GVFlt -{ - public class GVFltCallbacks - { - /// - /// This struct must remain here for DiskLayout9to10Upgrade_BackgroundAndPlaceholderListToFileBased - /// - /// - /// This struct should only be used by the upgrader, it has been replaced by GVFS.Virtualization.Background.FileSystemTask - /// - [Serializable] - public struct BackgroundGitUpdate - { - // This enum must be present or the BinarySerializer will always deserialze Operation as 0 - public enum OperationType - { - Invalid = 0, - } - - public OperationType Operation { get; set; } - public string VirtualPath { get; set; } - public string OldVirtualPath { get; set; } - - // Used by the logging in the upgrader - public override string ToString() - { - return JsonConvert.SerializeObject(this); - } - } - } -} diff --git a/GVFS/GVFS.Hooks/GVFS.Hooks.csproj b/GVFS/GVFS.Hooks/GVFS.Hooks.csproj index f5cd8a1eb..b45fbbeef 100644 --- a/GVFS/GVFS.Hooks/GVFS.Hooks.csproj +++ b/GVFS/GVFS.Hooks/GVFS.Hooks.csproj @@ -2,12 +2,11 @@ Exe - net471 true - + @@ -120,3 +119,4 @@ + diff --git a/GVFS/GVFS.Hooks/Program.Worktree.cs b/GVFS/GVFS.Hooks/Program.Worktree.cs index 325532a37..40c768ce0 100644 --- a/GVFS/GVFS.Hooks/Program.Worktree.cs +++ b/GVFS/GVFS.Hooks/Program.Worktree.cs @@ -51,22 +51,72 @@ private static void RunWorktreePreCommand(string[] args) private static void RunWorktreePostCommand(string[] args) { string subcommand = GetWorktreeSubcommand(args); + int? gitExitCode = GetHookExitCode(args); + + // Treat null (missing arg) the same as 0 — older Git versions + // may not pass --exit_code, and we should run post-processing + // in that case for backward compatibility. + bool gitSucceeded = gitExitCode == null || gitExitCode == 0; + switch (subcommand) { case "add": - MountNewWorktree(args); + if (gitSucceeded) + { + MountNewWorktree(args); + } + break; case "remove": + // Always run cleanup regardless of git exit code — need to + // remount if remove failed, and clean markers either way. RemountWorktreeIfRemoveFailed(args); CleanupSkipCleanCheckMarker(args); break; case "move": - // Mount at the new location after git moved the directory - MountMovedWorktree(args); + if (gitSucceeded) + { + MountMovedWorktree(args); + } + else + { + // Move failed — the pre-hook already unmounted the old + // location. Remount so the worktree remains usable. + RemountWorktreeIfMoveFailed(args); + } + break; } } + /// + /// Attempts to mount GVFS for a worktree, retrying on transient failures. + /// The first attempt shows output to the console; retries are quiet. + /// Returns true if mount succeeded. + /// + private static bool TryMountWithRetry(string fullPath) + { + int[] retryDelaysMs = { 100, 250 }; + + ProcessResult result = ProcessHelper.Run("gvfs", $"mount \"{fullPath}\"", redirectOutput: false); + if (result.ExitCode == 0) + { + return true; + } + + for (int retry = 0; retry < retryDelaysMs.Length; retry++) + { + System.Threading.Thread.Sleep(retryDelaysMs[retry]); + result = ProcessHelper.Run("gvfs", $"mount \"{fullPath}\"", redirectOutput: true); + if (result.ExitCode == 0) + { + return true; + } + } + + return false; + } + private static void UnmountWorktreeByArg(string[] args) { string worktreePath = GetWorktreePathArg(args); @@ -106,6 +156,27 @@ private static void RemountWorktreeIfRemoveFailed(string[] args) } } + /// + /// If git worktree move failed, remount at the original location. + /// The pre-hook unmounted the worktree before the move attempt; + /// on failure, the directory hasn't moved so we remount in place. + /// + private static void RemountWorktreeIfMoveFailed(string[] args) + { + string worktreePath = GetWorktreePathArg(args); + if (string.IsNullOrEmpty(worktreePath)) + { + return; + } + + string fullPath = ResolvePath(worktreePath); + string dotGitFile = Path.Combine(fullPath, ".git"); + if (Directory.Exists(fullPath) && File.Exists(dotGitFile)) + { + ProcessHelper.Run("gvfs", $"mount \"{fullPath}\"", redirectOutput: false); + } + } + /// /// Remove the skip-clean-check marker if it still exists after /// worktree remove completes (e.g., if the remove failed and the @@ -335,22 +406,32 @@ private static void MountNewWorktree(string[] args) // Disable hooks via core.hookspath — the worktree's GVFS mount // doesn't exist yet, so post-index-change would fail trying // to connect to a pipe that hasn't been created. + bool checkoutSucceeded = false; string emptyVfsHook = Path.Combine(fullPath, ".vfs-empty-hook"); try { File.WriteAllText(emptyVfsHook, "#!/bin/sh\nprintf \".gitattributes\\n\"\n"); string emptyVfsHookGitPath = emptyVfsHook.Replace('\\', '/'); - ProcessHelper.Run( + ProcessResult checkoutResult = ProcessHelper.Run( "git", $"-C \"{fullPath}\" -c core.virtualfilesystem=\"'{emptyVfsHookGitPath}'\" -c core.hookspath= checkout -f HEAD", redirectOutput: false); + checkoutSucceeded = checkoutResult.ExitCode == 0; } finally { File.Delete(emptyVfsHook); } + if (!checkoutSucceeded) + { + Console.Error.WriteLine( + $"warning: worktree checkout failed for '{fullPath}'.\n" + + $"The worktree may not be fully initialized. Run 'gvfs mount \"{fullPath}\"' to recover."); + return; + } + // Hydrate .gitattributes — copy from the primary enlistment. if (wtInfo?.SharedGitDir != null) { @@ -363,8 +444,13 @@ private static void MountNewWorktree(string[] args) } } - // Now mount GVFS — the index exists for GitIndexProjection - ProcessHelper.Run("gvfs", $"mount \"{fullPath}\"", redirectOutput: false); + // Mount GVFS with retry for transient contention (e.g. concurrent adds) + if (!TryMountWithRetry(fullPath)) + { + Console.Error.WriteLine( + $"warning: failed to mount GVFS for worktree '{fullPath}' after multiple attempts.\n" + + $"Files may not be visible. Run 'gvfs mount \"{fullPath}\"' to recover."); + } } } @@ -383,7 +469,12 @@ private static void MountMovedWorktree(string[] args) string dotGitFile = Path.Combine(fullPath, ".git"); if (File.Exists(dotGitFile)) { - ProcessHelper.Run("gvfs", $"mount \"{fullPath}\"", redirectOutput: false); + if (!TryMountWithRetry(fullPath)) + { + Console.Error.WriteLine( + $"warning: failed to mount GVFS for moved worktree '{fullPath}' after multiple attempts.\n" + + $"Files may not be visible. Run 'gvfs mount \"{fullPath}\"' to recover."); + } } } } diff --git a/GVFS/GVFS.Hooks/Program.cs b/GVFS/GVFS.Hooks/Program.cs index c04f0c778..e9e3fb537 100644 --- a/GVFS/GVFS.Hooks/Program.cs +++ b/GVFS/GVFS.Hooks/Program.cs @@ -508,6 +508,28 @@ private static bool IsAlias(string command) return !string.IsNullOrEmpty(result.Output); } + /// + /// Extracts the git exit code from hook args. Git appends --exit_code=N + /// to post-command hook arguments. Returns null if the argument is + /// missing or unparseable — callers decide what "no exit code" means + /// for their use case. + /// + private static int? GetHookExitCode(string[] args) + { + for (int i = args.Length - 1; i >= 0; i--) + { + if (args[i].StartsWith("--exit_code=")) + { + if (int.TryParse(args[i].Substring("--exit_code=".Length), out int code)) + { + return code; + } + } + } + + return null; + } + private static string GetGitCommandSessionId() { try diff --git a/GVFS/GVFS.Installers/GVFS.Installers.csproj b/GVFS/GVFS.Installers/GVFS.Installers.csproj index 7ae37dee5..5961b569b 100644 --- a/GVFS/GVFS.Installers/GVFS.Installers.csproj +++ b/GVFS/GVFS.Installers/GVFS.Installers.csproj @@ -1,7 +1,6 @@ - net471 false $(RepoOutPath)GVFS.Payload\bin\$(Configuration)\win-x64\ @@ -12,8 +11,8 @@ - - + + @@ -45,3 +44,4 @@ + diff --git a/GVFS/GVFS.Installers/Setup.iss b/GVFS/GVFS.Installers/Setup.iss index 886da1042..10765dddb 100644 --- a/GVFS/GVFS.Installers/Setup.iss +++ b/GVFS/GVFS.Installers/Setup.iss @@ -15,7 +15,6 @@ #define GVFSConfigFileName "gvfs.config" #define GVFSStatuscacheTokenFileName "EnableGitStatusCacheToken.dat" #define ServiceName "GVFS.Service" -#define ServiceUIName "VFS For Git" [Setup] AppId={{489CA581-F131-4C28-BE04-4FB178933E6D} @@ -35,7 +34,7 @@ OutputDir=Setup Compression=lzma2 InternalCompressLevel=ultra64 SolidCompression=yes -MinVersion=10.0.14374 +MinVersion=10.0.17763 DisableDirPage=yes DisableReadyPage=yes SetupIconFile="{#LayoutDir}\GitVirtualFileSystem.ico" @@ -43,7 +42,7 @@ ArchitecturesInstallIn64BitMode=x64compatible ArchitecturesAllowed=x64compatible WizardImageStretch=no WindowResizable=no -CloseApplications=yes +CloseApplications=no ChangesEnvironment=yes RestartIfNeededByRun=yes @@ -60,15 +59,18 @@ Name: "full"; Description: "Full installation"; Flags: iscustom; Type: files; Name: "{app}\ucrtbase.dll" [Files] -DestDir: "{app}"; Flags: ignoreversion recursesubdirs; Source:"{#LayoutDir}\*" -DestDir: "{app}"; Flags: ignoreversion; Source:"{#LayoutDir}\GVFS.Service.exe"; AfterInstall: InstallGVFSService +; Normal install: all files go to {app}, service gets AfterInstall callback +DestDir: "{app}"; Flags: ignoreversion recursesubdirs; Source:"{#LayoutDir}\*"; Check: IsNormalInstall +DestDir: "{app}"; Flags: ignoreversion; Source:"{#LayoutDir}\GVFS.Service.exe"; AfterInstall: InstallGVFSService; Check: IsNormalInstall +; Staging install: most files go to {app}\PendingUpgrade, but GVFS.Service.exe +; goes directly to {app} so the restarted service has PendingUpgradeHandler code. +; The service is briefly stopped/restarted (mounts are independent processes). +DestDir: "{app}\PendingUpgrade"; Flags: ignoreversion recursesubdirs; Source:"{#LayoutDir}\*"; Check: IsStagingInstall +DestDir: "{app}"; Flags: ignoreversion; Source:"{#LayoutDir}\GVFS.Service.exe"; Check: IsStagingInstall [Dirs] Name: "{app}\ProgramData\{#ServiceName}"; Permissions: users-readexec -[Icons] -Name: "{commonstartmenu}\{#ServiceUIName}"; Filename: "{app}\GVFS.Service.UI.exe"; AppUserModelID: "GVFS" - [UninstallDelete] ; Deletes the entire installation directory, including files and subdirectories Type: filesandordirs; Name: "{app}"; @@ -88,6 +90,17 @@ Root: HKLM; SubKey: "{#GvFltAutologgerKey}"; Flags: deletekey [Code] var ExitCode: Integer; + KeepMountsRunning: Boolean; + +function IsNormalInstall(): Boolean; +begin + Result := not KeepMountsRunning; +end; + +function IsStagingInstall(): Boolean; +begin + Result := KeepMountsRunning; +end; function NeedsAddPath(Param: string): boolean; var @@ -153,11 +166,66 @@ var ResultCode: integer; begin Log('StopService: stopping: ' + ServiceName); - // ErrorCode 1060 means service not installed, 1062 means service not started - if not Exec(ExpandConstant('{sys}\SC.EXE'), 'stop ' + ServiceName, '', SW_HIDE, ewWaitUntilTerminated, ResultCode) and (ResultCode <> 1060) and (ResultCode <> 1062) then + if not Exec(ExpandConstant('{sys}\SC.EXE'), 'stop ' + ServiceName, '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then begin + Log('StopService: Failed to launch sc.exe'); RaiseException('Fatal: Could not stop service: ' + ServiceName); end; + // 1060 = service not installed, 1062 = service not started + if (ResultCode <> 0) and (ResultCode <> 1060) and (ResultCode <> 1062) then + begin + Log('StopService: sc stop returned error code ' + IntToStr(ResultCode)); + RaiseException('Fatal: Could not stop service: ' + ServiceName + ' (exit code ' + IntToStr(ResultCode) + ')'); + end; +end; + +procedure WaitForServiceProcessToExit(ServiceName: string); +var + ResultCode: integer; + Attempts: integer; + TempFile: string; + QueryOutput: ansiString; +begin + // sc stop/delete returns before the service process actually exits. + // Poll sc query until the service is fully gone (1060) or stopped. + Attempts := 0; + TempFile := ExpandConstant('{tmp}\~scquery.txt'); + while Attempts < 30 do + begin + if Exec(ExpandConstant('{cmd}'), '/C "' + ExpandConstant('{sys}\SC.EXE') + '" query ' + ServiceName + ' > "' + TempFile + '" 2>&1', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then + begin + // 1060 = service does not exist (fully deleted and process exited) + if ResultCode = 1060 then + begin + Log('WaitForServiceProcessToExit: Service no longer exists'); + break; + end; + if LoadStringFromFile(TempFile, QueryOutput) then + begin + if Pos('STOPPED', QueryOutput) > 0 then + begin + Log('WaitForServiceProcessToExit: Service is stopped'); + break; + end; + end; + end + else + begin + Log('WaitForServiceProcessToExit: sc query failed, assuming service is gone'); + break; + end; + Attempts := Attempts + 1; + Log('WaitForServiceProcessToExit: Waiting for service to stop (attempt ' + IntToStr(Attempts) + ')'); + Sleep(1000); + end; + if Attempts >= 30 then + begin + if LoadStringFromFile(TempFile, QueryOutput) then + Log('WaitForServiceProcessToExit: Timed out. Last sc query output: ' + QueryOutput) + else + Log('WaitForServiceProcessToExit: Timed out waiting for service to stop'); + end; + DeleteFile(TempFile); end; procedure UninstallService(ServiceName: string; ShowProgress: boolean); @@ -182,6 +250,8 @@ begin RaiseException('Fatal: Could not uninstall service: ' + ServiceName); end; + WaitForServiceProcessToExit(ServiceName); + if (ShowProgress) then begin WizardForm.StatusLabel.Caption := 'Waiting for pending ' + ServiceName + ' deletion to complete. This may take a while.'; @@ -249,36 +319,36 @@ begin end; end; -procedure StartGVFSServiceUI(); +procedure StagingUpdateService(); var ResultCode: integer; + StatusText: string; begin - if GetEnv('GVFS_UNATTENDED') = '1' then - begin - Log('StartGVFSServiceUI: Skipping launching GVFS.Service.UI'); - end - else if ExecAsOriginalUser(ExpandConstant('{app}\GVFS.Service.UI.exe'), '', '', SW_HIDE, ewNoWait, ResultCode) then - begin - Log('StartGVFSServiceUI: Successfully launched GVFS.Service.UI'); - end - else - begin - Log('StartGVFSServiceUI: Failed to launch GVFS.Service.UI'); - end; -end; + // In staging mode: the service was stopped in PrepareToInstall so its exe + // could be replaced. Now start it with the new binary. The new service has + // PendingUpgradeHandler which will complete the upgrade on next restart + // when no mounts are running. + StatusText := WizardForm.StatusLabel.Caption; + WizardForm.StatusLabel.Caption := 'Starting GVFS.Service.'; + WizardForm.ProgressGauge.Style := npbstMarquee; -procedure StopGVFSServiceUI(); -var - ResultCode: integer; -begin - if Exec('powershell.exe', '-NoProfile "Stop-Process -Name GVFS.Service.UI"', '', SW_HIDE, ewNoWait, ResultCode) then - begin - Log('StopGVFSServiceUI: Successfully stopped GVFS.Service.UI'); - end - else - begin - RaiseException('Fatal: Could not stop process: GVFS.Service.UI'); - end; + try + Log('StagingUpdateService: Starting service with new binary'); + if Exec(ExpandConstant('{sys}\SC.EXE'), 'start GVFS.Service', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then + begin + if ResultCode <> 0 then + Log('StagingUpdateService: Warning - sc start returned error code ' + IntToStr(ResultCode)); + end + else + begin + Log('StagingUpdateService: Warning - could not launch sc.exe'); + end; + + WriteOnDiskVersion16CapableFile(); + finally + WizardForm.StatusLabel.Caption := StatusText; + WizardForm.ProgressGauge.Style := npbstNormal; + end; end; function DeleteFileIfItExists(FilePath: string) : Boolean; @@ -521,39 +591,6 @@ begin MigrateFile(CommonAppDataDir + '\{#ServiceName}\{#GVFSStatuscacheTokenFileName}', SecureAppDataDir + '\{#ServiceName}\{#GVFSStatuscacheTokenFileName}'); end; -function ConfirmUnmountAll(): Boolean; -var - MsgBoxResult: integer; - Repos: ansiString; - ResultCode: integer; - MsgBoxText: string; -begin - Result := False; - if ExecWithResult('gvfs.exe', 'service --list-mounted', '', SW_HIDE, ewWaitUntilTerminated, ResultCode, Repos) then - begin - if Repos = '' then - begin - Result := False; - end - else - begin - if ResultCode = 0 then - begin - MsgBoxText := 'The following repos are currently mounted:' + #13#10 + Repos + #13#10 + 'Setup needs to unmount all repos before it can proceed, and those repos will be unavailable while setup is running. Do you want to continue?'; - MsgBoxResult := SuppressibleMsgBox(MsgBoxText, mbConfirmation, MB_OKCANCEL, IDOK); - if (MsgBoxResult = IDOK) then - begin - Result := True; - end - else - begin - Abort(); - end; - end; - end; - end; -end; - function EnsureGvfsNotRunning(): Boolean; var MsgBoxResult: integer; @@ -683,13 +720,26 @@ begin case CurStep of ssInstall: begin - UninstallService('GVFS.Service', True); + if not KeepMountsRunning then + UninstallService('GVFS.Service', True); end; ssPostInstall: begin + if KeepMountsRunning then + begin + // All staged files have been written to PendingUpgrade. + // Write .ready marker so the service knows the staging is + // complete and safe to apply. + SaveStringToFile(ExpandConstant('{app}\PendingUpgrade\.ready'), '', False); + Log('CurStepChanged: Wrote PendingUpgrade .ready marker'); + + // Start the service AFTER .ready is written. Previously this + // was an AfterInstall hook on GVFS.Service.exe, but that races: + // the service's debounce timer could fire before .ready exists. + StagingUpdateService(); + end; MigrateConfigAndStatusCacheFiles(); - StartGVFSServiceUI(); - if ExpandConstant('{param:REMOUNTREPOS|true}') = 'true' then + if (not KeepMountsRunning) and (ExpandConstant('{param:REMOUNTREPOS|true}') = 'true') then begin MountRepos(); end @@ -707,7 +757,6 @@ begin case CurStep of usUninstall: begin - StopGVFSServiceUI(); UninstallService('GVFS.Service', False); RemovePath(ExpandConstant('{app}')); end; @@ -715,23 +764,125 @@ begin end; function PrepareToInstall(var NeedsRestart: Boolean): String; +var + MsgBoxResult: integer; + Repos: ansiString; + ResultCode: integer; + HasMounts: Boolean; begin NeedsRestart := False; + KeepMountsRunning := False; Result := ''; SetNuGetFeedIfNecessary(); - if ConfirmUnmountAll() then + + // Check for mounted repos by querying the service, and also check for + // running GVFS processes (a mount can be running without being registered + // in the service's repo-registry, e.g., after a reinstall). + HasMounts := False; + if ExecWithResult('gvfs.exe', 'service --list-mounted', '', SW_HIDE, ewWaitUntilTerminated, ResultCode, Repos) then + begin + if (ResultCode = 0) and (Repos <> '') then + HasMounts := True; + end; + if (not HasMounts) and IsGVFSRunning() then begin - if ExpandConstant('{param:REMOUNTREPOS|true}') = 'true' then + HasMounts := True; + Repos := '(GVFS processes detected)'; + Log('PrepareToInstall: No registered mounts but GVFS processes are running'); + end; + + if HasMounts then + begin + if WizardSilent() then begin - UnmountRepos(); + // Silent mode: STAGEIFMOUNTED=true stages files instead of unmounting. + // Default: false (clean upgrade, matching pre-existing behavior). + KeepMountsRunning := ExpandConstant('{param:STAGEIFMOUNTED|false}') = 'true'; + if KeepMountsRunning then + Log('PrepareToInstall: Silent mode with mounted repos, KeepMountsRunning=True') + else + Log('PrepareToInstall: Silent mode with mounted repos, KeepMountsRunning=False'); end + else + begin + // Interactive mode: let user choose + MsgBoxResult := SuppressibleMsgBox( + 'The following repos are currently mounted:' + #13#10 + Repos + #13#10#13#10 + + 'Click Yes to keep repos mounted during the upgrade.' + #13#10 + + 'The upgrade will complete automatically when all repos are unmounted.' + #13#10#13#10 + + 'Click No to unmount all repos now and upgrade without restart.' + #13#10 + + 'Repos will be temporarily unavailable during the upgrade.', + mbConfirmation, MB_YESNOCANCEL, IDYES); + if MsgBoxResult = IDYES then + KeepMountsRunning := True + else if MsgBoxResult = IDNO then + KeepMountsRunning := False + else + begin + Result := 'Installation cancelled.'; + exit; + end; + end; end; - if not EnsureGvfsNotRunning() then + + if KeepMountsRunning then begin - Abort(); + // Staging mode: most files go to {app}\PendingUpgrade\ via [Files] entries + // with Check: IsStagingInstall. GVFS.Service.exe goes directly to {app}. + // Clean up any leftover staging dirs from a prior attempt first, + // so we don't mix files from different upgrade versions. + if DirExists(ExpandConstant('{app}\PendingUpgrade')) then + begin + Log('PrepareToInstall: Removing stale PendingUpgrade from prior staging attempt'); + DelTree(ExpandConstant('{app}\PendingUpgrade'), True, True, True); + end; + if DirExists(ExpandConstant('{app}\PreviousVersion')) then + begin + Log('PrepareToInstall: Removing stale PreviousVersion from prior staging attempt'); + DelTree(ExpandConstant('{app}\PreviousVersion'), True, True, True); + end; + // Stop the service now so its exe is unlocked for replacement. + // Mounts are independent processes and unaffected. + Log('PrepareToInstall: Staging mode. Stopping service for exe replacement.'); + StopService('GVFS.Service'); + WaitForServiceProcessToExit('GVFS.Service'); + end + else + begin + // Clean upgrade: unmount, stop everything, replace files directly. + // Remove any leftover PendingUpgrade or PreviousVersion from a + // previous staging install so stale files don't interfere with + // the fresh install. + if DirExists(ExpandConstant('{app}\PendingUpgrade')) then + begin + Log('PrepareToInstall: Removing leftover PendingUpgrade directory'); + DelTree(ExpandConstant('{app}\PendingUpgrade'), True, True, True); + end; + if DirExists(ExpandConstant('{app}\PreviousVersion')) then + begin + Log('PrepareToInstall: Removing leftover PreviousVersion directory'); + DelTree(ExpandConstant('{app}\PreviousVersion'), True, True, True); + end; + if HasMounts then + begin + UnmountRepos(); + end; + // With CloseApplications=no, Restart Manager won't kill GVFS + // processes. If unmount-all didn't clean up everything (e.g. + // registry was empty), force-kill remaining processes since + // the user already consented to a full upgrade. + if IsGVFSRunning() then + begin + Log('PrepareToInstall: GVFS processes still running after unmount, force-killing'); + Exec('powershell.exe', '-NoProfile "Get-Process gvfs,gvfs.mount -ErrorAction SilentlyContinue | Stop-Process -Force"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); + Sleep(2000); + end; + if not EnsureGvfsNotRunning() then + begin + Abort(); + end; + StopService('GVFS.Service'); + UninstallGvFlt(); + UninstallProjFSIfNecessary(); end; - StopService('GVFS.Service'); - StopGVFSServiceUI(); - UninstallGvFlt(); - UninstallProjFSIfNecessary(); end; diff --git a/GVFS/GVFS.MSBuild/GVFS.MSBuild.csproj b/GVFS/GVFS.MSBuild/GVFS.MSBuild.csproj index 1505e24e0..bd33c1678 100644 --- a/GVFS/GVFS.MSBuild/GVFS.MSBuild.csproj +++ b/GVFS/GVFS.MSBuild/GVFS.MSBuild.csproj @@ -2,12 +2,14 @@ netstandard2.0 + false + false false - - + + diff --git a/GVFS/GVFS.Mount/GVFS.Mount.csproj b/GVFS/GVFS.Mount/GVFS.Mount.csproj index 83d89be63..271d0cc87 100644 --- a/GVFS/GVFS.Mount/GVFS.Mount.csproj +++ b/GVFS/GVFS.Mount/GVFS.Mount.csproj @@ -2,21 +2,18 @@ Exe - net471 false - Content - PreserveNewest - Build;DebugSymbolsProjectOutputGroup - + + diff --git a/GVFS/GVFS.Mount/InProcessMount.cs b/GVFS/GVFS.Mount/InProcessMount.cs index 6fedfb3ed..24041dec3 100644 --- a/GVFS/GVFS.Mount/InProcessMount.cs +++ b/GVFS/GVFS.Mount/InProcessMount.cs @@ -1,4 +1,4 @@ -using GVFS.Common; +using GVFS.Common; using GVFS.Common.Database; using GVFS.Common.FileSystem; using GVFS.Common.Git; @@ -9,7 +9,6 @@ using GVFS.PlatformLoader; using GVFS.Virtualization; using GVFS.Virtualization.FileSystem; -using Newtonsoft.Json; using System; using System.Collections.Generic; using System.Diagnostics; @@ -273,7 +272,14 @@ private void MountWithLockAcquired(EventLevel verbosity, Keywords keywords) this.MountAndStartWorkingDirectoryCallbacks(this.cacheServer); - Console.Title = "GVFS " + ProcessHelper.GetCurrentProcessVersion() + " - " + this.enlistment.EnlistmentRoot; + try + { + Console.Title = "GVFS " + ProcessHelper.GetCurrentProcessVersion() + " - " + this.enlistment.EnlistmentRoot; + } + catch (IOException) + { + // Console.Title throws when the process has no console (e.g. started as background/hidden process) + } this.tracer.RelatedEvent( EventLevel.Informational, @@ -971,7 +977,7 @@ private void HandlePostFetchJobRequest(NamedPipeMessages.Message message, NamedP NamedPipeMessages.RunPostFetchJob.Response response; if (this.currentState == MountState.Ready) { - List packIndexes = JsonConvert.DeserializeObject>(message.Body); + List packIndexes = GVFSJsonOptions.Deserialize>(message.Body); this.maintenanceScheduler.EnqueueOneTimeStep(new PostFetchStep(this.context, packIndexes)); response = new NamedPipeMessages.RunPostFetchJob.Response(NamedPipeMessages.RunPostFetchJob.QueuedResult); @@ -1248,7 +1254,7 @@ private void ValidateGVFSVersion(ServerGVFSConfig config) string warningMessage = "WARNING: Unable to validate your GVFS version" + Environment.NewLine; if (config == null) { - warningMessage += "Could not query valid GVFS versions from: " + Uri.EscapeUriString(this.enlistment.RepoUrl); + warningMessage += "Could not query valid GVFS versions from: " + Uri.EscapeDataString(this.enlistment.RepoUrl); } else { diff --git a/GVFS/GVFS.Mount/InProcessMountVerb.cs b/GVFS/GVFS.Mount/InProcessMountVerb.cs index 17d373b7c..309d56fbd 100644 --- a/GVFS/GVFS.Mount/InProcessMountVerb.cs +++ b/GVFS/GVFS.Mount/InProcessMountVerb.cs @@ -1,16 +1,15 @@ -using CommandLine; using GVFS.Common; using GVFS.Common.Git; using GVFS.Common.Http; using GVFS.Common.Tracing; using System; -using System.ComponentModel; +using System.CommandLine; +using System.ComponentModel; using System.IO; using System.Runtime.InteropServices; namespace GVFS.Mount { - [Verb("mount", HelpText = "Starts the background mount process")] public class InProcessMountVerb { private TextWriter output; @@ -25,53 +24,70 @@ public InProcessMountVerb() public ReturnCode ReturnCode { get; private set; } - [Option( - 'v', - GVFSConstants.VerbParameters.Mount.Verbosity, - Default = GVFSConstants.VerbParameters.Mount.DefaultVerbosity, - Required = false, - HelpText = "Sets the verbosity of console logging. Accepts: Verbose, Informational, Warning, Error")] public string Verbosity { get; set; } - [Option( - 'k', - GVFSConstants.VerbParameters.Mount.Keywords, - Default = GVFSConstants.VerbParameters.Mount.DefaultKeywords, - Required = false, - HelpText = "A CSV list of logging filter keywords. Accepts: Any, Network")] public string KeywordsCsv { get; set; } - [Option( - 'd', - GVFSConstants.VerbParameters.Mount.DebugWindow, - Default = false, - Required = false, - HelpText = "Show the debug window. By default, all output is written to a log file and no debug window is shown.")] public bool ShowDebugWindow { get; set; } - [Option( - 's', - GVFSConstants.VerbParameters.Mount.StartedByService, - Default = "false", - Required = false, - HelpText = "Service initiated mount.")] - public string StartedByService { get; set; } - - [Option( - 'b', - GVFSConstants.VerbParameters.Mount.StartedByVerb, - Default = false, - Required = false, - HelpText = "Verb initiated mount.")] + public string StartedByService { get; set; } + public bool StartedByVerb { get; set; } - [Value( - 0, - Required = true, - MetaName = "Enlistment Root Path", - HelpText = "Full or relative path to the GVFS enlistment root")] public string EnlistmentRootPathParameter { get; set; } + public static RootCommand BuildRootCommand() + { + RootCommand rootCommand = new RootCommand("Starts the background mount process"); + + Argument enlistmentRootPathArg = new Argument("enlistment-root-path") + { + Arity = ArgumentArity.ExactlyOne + }; + rootCommand.Add(enlistmentRootPathArg); + + Option verbosityOption = new Option("--verbosity", new[] { "-v" }) + { + Description = "Sets the verbosity of console logging", + DefaultValueFactory = (_) => GVFSConstants.VerbParameters.Mount.DefaultVerbosity + }; + rootCommand.Add(verbosityOption); + + Option keywordsOption = new Option("--keywords", new[] { "-k" }) + { + Description = "A CSV list of logging filter keywords", + DefaultValueFactory = (_) => GVFSConstants.VerbParameters.Mount.DefaultKeywords + }; + rootCommand.Add(keywordsOption); + + Option debugWindowOption = new Option("--debug-window", new[] { "-d" }) { Description = "Show the debug window" }; + rootCommand.Add(debugWindowOption); + + Option startedByServiceOption = new Option("--StartedByService", new[] { "-s" }) + { + Description = "Service initiated mount.", + DefaultValueFactory = (_) => "false" + }; + rootCommand.Add(startedByServiceOption); + + Option startedByVerbOption = new Option("--StartedByVerb", new[] { "-b" }) { Description = "Verb initiated mount." }; + rootCommand.Add(startedByVerbOption); + + rootCommand.SetAction((ParseResult result) => + { + InProcessMountVerb verb = new InProcessMountVerb(); + verb.EnlistmentRootPathParameter = result.GetValue(enlistmentRootPathArg); + verb.Verbosity = result.GetValue(verbosityOption) ?? ""; + verb.KeywordsCsv = result.GetValue(keywordsOption) ?? ""; + verb.ShowDebugWindow = result.GetValue(debugWindowOption); + verb.StartedByService = result.GetValue(startedByServiceOption) ?? "false"; + verb.StartedByVerb = result.GetValue(startedByVerbOption); + verb.Execute(); + }); + + return rootCommand; + } + public void InitializeDefaultParameterValues() { this.Verbosity = GVFSConstants.VerbParameters.Mount.DefaultVerbosity; diff --git a/GVFS/GVFS.Mount/InternalsVisibleTo.cs b/GVFS/GVFS.Mount/InternalsVisibleTo.cs new file mode 100644 index 000000000..200018c1f --- /dev/null +++ b/GVFS/GVFS.Mount/InternalsVisibleTo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("GVFS.CommandLine.Tests")] diff --git a/GVFS/GVFS.Mount/Program.cs b/GVFS/GVFS.Mount/Program.cs index 87a96922d..96584e113 100644 --- a/GVFS/GVFS.Mount/Program.cs +++ b/GVFS/GVFS.Mount/Program.cs @@ -1,7 +1,10 @@ -using CommandLine; +using System.CommandLine; +using System.Runtime.CompilerServices; using GVFS.PlatformLoader; using System; +[assembly: InternalsVisibleTo("GVFS.CommandLine.Tests")] + namespace GVFS.Mount { public class Program @@ -11,8 +14,8 @@ public static void Main(string[] args) GVFSPlatformLoader.Initialize(); try { - Parser.Default.ParseArguments(args) - .WithParsed(mount => mount.Execute()); + RootCommand rootCommand = BuildRootCommand(); + rootCommand.Parse(args).Invoke(); } catch (MountAbortedException e) { @@ -20,5 +23,7 @@ public static void Main(string[] args) Environment.Exit((int)e.Verb.ReturnCode); } } + + internal static RootCommand BuildRootCommand() => InProcessMountVerb.BuildRootCommand(); } } diff --git a/GVFS/GVFS.Payload/GVFS.Payload.csproj b/GVFS/GVFS.Payload/GVFS.Payload.csproj index 1311bc87d..c87428e9f 100644 --- a/GVFS/GVFS.Payload/GVFS.Payload.csproj +++ b/GVFS/GVFS.Payload/GVFS.Payload.csproj @@ -1,7 +1,6 @@ - + - net471 false @@ -11,25 +10,12 @@ - - - - - - - - - - - - - - - + + - + @@ -39,21 +25,17 @@ + $(OutputPath)\GVFS.VirtualFileSystemHook.exe;"> Microsoft400 false + diff --git a/GVFS/GVFS.Payload/layout.bat b/GVFS/GVFS.Payload/layout.bat index ebdae19c2..e1ff77270 100644 --- a/GVFS/GVFS.Payload/layout.bat +++ b/GVFS/GVFS.Payload/layout.bat @@ -14,18 +14,12 @@ IF "%~2" == "" ( ) IF "%~3" == "" ( - ECHO error: missing ProjFS path - ECHO. - GOTO USAGE -) - -IF "%~4" == "" ( ECHO error: missing VCRuntime path ECHO. GOTO USAGE ) -IF "%~5" == "" ( +IF "%~4" == "" ( ECHO error: missing output path ECHO. GOTO USAGE @@ -33,19 +27,17 @@ IF "%~5" == "" ( SET CONFIGURATION=%1 SET GVFSVERSION=%2 -SET PROJFS=%3 -SET VCRUNTIME=%4 -SET OUTPUT=%5 +SET VCRUNTIME=%3 +SET OUTPUT=%4 SET ROOT=%~dp0..\.. SET BUILD_OUT="%ROOT%\..\out" -SET MANAGED_OUT_FRAGMENT=bin\%CONFIGURATION%\net471\win-x64 +SET MANAGED_OUT_FRAGMENT=bin\%CONFIGURATION%\net10.0-windows10.0.17763.0\win-x64\publish SET NATIVE_OUT_FRAGMENT=bin\x64\%CONFIGURATION% ECHO Copying files... -xcopy /Y %PROJFS%\filter\PrjFlt.sys %OUTPUT%\Filter\ -xcopy /Y %PROJFS%\filter\prjflt.inf %OUTPUT%\Filter\ -xcopy /Y %PROJFS%\lib\ProjectedFSLib.dll %OUTPUT%\ProjFS\ +REM ProjFS is now a Windows Optional Feature (available since Windows 10 1809). +REM The filter driver and native library are no longer bundled from a NuGet package. xcopy /Y %VCRUNTIME%\lib\x64\msvcp140.dll %OUTPUT% xcopy /Y %VCRUNTIME%\lib\x64\msvcp140_1.dll %OUTPUT% xcopy /Y %VCRUNTIME%\lib\x64\msvcp140_2.dll %OUTPUT% @@ -54,7 +46,6 @@ xcopy /Y /S %BUILD_OUT%\GVFS\%MANAGED_OUT_FRAGMENT%\* %OUTPUT% xcopy /Y /S %BUILD_OUT%\GVFS.Hooks\%MANAGED_OUT_FRAGMENT%\* %OUTPUT% xcopy /Y /S %BUILD_OUT%\GVFS.Mount\%MANAGED_OUT_FRAGMENT%\* %OUTPUT% xcopy /Y /S %BUILD_OUT%\GVFS.Service\%MANAGED_OUT_FRAGMENT%\* %OUTPUT% -xcopy /Y /S %BUILD_OUT%\GVFS.Service.UI\%MANAGED_OUT_FRAGMENT%\* %OUTPUT% xcopy /Y /S %BUILD_OUT%\GitHooksLoader\%NATIVE_OUT_FRAGMENT%\* %OUTPUT% xcopy /Y /S %BUILD_OUT%\GVFS.PostIndexChangedHook\%NATIVE_OUT_FRAGMENT%\* %OUTPUT% xcopy /Y /S %BUILD_OUT%\GVFS.ReadObjectHook\%NATIVE_OUT_FRAGMENT%\* %OUTPUT% @@ -65,15 +56,21 @@ REM Remove unused LibGit2 files RMDIR /S /Q %OUTPUT%\lib REM Remove files for x86 (not supported) RMDIR /S /Q %OUTPUT%\x86 +REM Remove stray managed artifacts (AOT binaries don't need these) +DEL /Q %OUTPUT%\*.runtimeconfig.json 2>nul +DEL /Q %OUTPUT%\*.deps.json 2>nul +REM Remove orphaned managed PDBs (these libraries are compiled into AOT exes) +DEL /Q %OUTPUT%\GVFS.Common.pdb 2>nul +DEL /Q %OUTPUT%\GVFS.Platform.Windows.pdb 2>nul +DEL /Q %OUTPUT%\GVFS.Virtualization.pdb 2>nul GOTO EOF :USAGE -ECHO usage: %~n0%~x0 ^ ^ ^ ^ ^ +ECHO usage: %~n0%~x0 ^ ^ ^ ^ ECHO. ECHO configuration Build configuration (Debug, Release). ECHO version GVFS version string. -ECHO projfs Path to GVFS.ProjFS NuGet package contents. ECHO vcruntime Path to GVFS.VCRuntime NuGet package contents. ECHO output Output directory. ECHO. diff --git a/GVFS/GVFS.PerfProfiling/GVFS.PerfProfiling.csproj b/GVFS/GVFS.PerfProfiling/GVFS.PerfProfiling.csproj index bf3ce6850..995d31921 100644 --- a/GVFS/GVFS.PerfProfiling/GVFS.PerfProfiling.csproj +++ b/GVFS/GVFS.PerfProfiling/GVFS.PerfProfiling.csproj @@ -2,7 +2,6 @@ Exe - net471 @@ -12,3 +11,4 @@ + diff --git a/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout10to11Upgrade_NewOperationType.cs b/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout10to11Upgrade_NewOperationType.cs deleted file mode 100644 index aa574ea8c..000000000 --- a/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout10to11Upgrade_NewOperationType.cs +++ /dev/null @@ -1,27 +0,0 @@ -using GVFS.Common.Tracing; -using GVFS.DiskLayoutUpgrades; - -namespace GVFS.Platform.Windows.DiskLayoutUpgrades -{ - public class DiskLayout10to11Upgrade_NewOperationType : DiskLayoutUpgrade.MajorUpgrade - { - protected override int SourceMajorVersion - { - get { return 10; } - } - - /// - /// Version 10 to 11 only added a new value to BackgroundGitUpdate.OperationType, - /// so we only need to bump the disk layout version version here. - /// - public override bool TryUpgrade(ITracer tracer, string enlistmentRoot) - { - if (!this.TryIncrementMajorVersion(tracer, enlistmentRoot)) - { - return false; - } - - return true; - } - } -} diff --git a/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout11to12Upgrade_SharedLocalCache.cs b/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout11to12Upgrade_SharedLocalCache.cs deleted file mode 100644 index d5f31218f..000000000 --- a/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout11to12Upgrade_SharedLocalCache.cs +++ /dev/null @@ -1,63 +0,0 @@ -using GVFS.Common; -using GVFS.Common.Tracing; -using GVFS.DiskLayoutUpgrades; -using System.IO; - -namespace GVFS.Platform.Windows.DiskLayoutUpgrades -{ - public class DiskLayout11to12Upgrade_SharedLocalCache : DiskLayoutUpgrade.MajorUpgrade - { - protected override int SourceMajorVersion - { - get { return 11; } - } - - /// - /// Version 11 to 12 added the shared local git objects cache. - /// - public override bool TryUpgrade(ITracer tracer, string enlistmentRoot) - { - string dotGVFSPath = Path.Combine(enlistmentRoot, GVFSPlatform.Instance.Constants.DotGVFSRoot); - string error; - if (!RepoMetadata.TryInitialize(tracer, dotGVFSPath, out error)) - { - tracer.RelatedError(nameof(this.TryUpgradeGitObjectPath) + ": Could not initialize repo metadata: " + error); - return false; - } - - if (!this.TryUpgradeGitObjectPath(tracer, enlistmentRoot)) - { - return false; - } - - RepoMetadata.Instance.SetLocalCacheRoot(string.Empty); - tracer.RelatedInfo("Set LocalCacheRoot to string.Empty"); - - if (!this.TryIncrementMajorVersion(tracer, enlistmentRoot)) - { - return false; - } - - return true; - } - - private bool TryUpgradeGitObjectPath(ITracer tracer, string enlistmentRoot) - { - string gitObjectsRoot; - string legacyDotGVFSGitObjectCachePath = Path.Combine(enlistmentRoot, GVFSPlatform.Instance.Constants.DotGVFSRoot, "gitObjectCache"); - if (Directory.Exists(legacyDotGVFSGitObjectCachePath)) - { - gitObjectsRoot = legacyDotGVFSGitObjectCachePath; - } - else - { - // Old version prior to \.gvfs\gitObjectCache cache - gitObjectsRoot = Path.Combine(enlistmentRoot, GVFSConstants.WorkingDirectoryRootName, GVFSConstants.DotGit.Objects.Root); - } - - RepoMetadata.Instance.SetGitObjectsRoot(gitObjectsRoot); - tracer.RelatedInfo("Set GitObjectsRoot: " + gitObjectsRoot); - return true; - } - } -} \ No newline at end of file diff --git a/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout12_0To12_1Upgrade_StatusAheadBehind.cs b/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout12_0To12_1Upgrade_StatusAheadBehind.cs deleted file mode 100644 index 2f90d8a43..000000000 --- a/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout12_0To12_1Upgrade_StatusAheadBehind.cs +++ /dev/null @@ -1,35 +0,0 @@ -using GVFS.Common.Tracing; -using GVFS.DiskLayoutUpgrades; -using System.Collections.Generic; - -namespace GVFS.Platform.Windows.DiskLayoutUpgrades -{ - public class DiskLayout12_0To12_1Upgrade_StatusAheadBehind : DiskLayoutUpgrade.MinorUpgrade - { - protected override int SourceMajorVersion - { - get { return 12; } - } - - protected override int SourceMinorVersion - { - get { return 0; } - } - - public override bool TryUpgrade(ITracer tracer, string enlistmentRoot) - { - if (!this.TrySetGitConfig( - tracer, - enlistmentRoot, - new Dictionary - { - { "status.aheadbehind", "false" }, - })) - { - return false; - } - - return this.TryIncrementMinorVersion(tracer, enlistmentRoot); - } - } -} diff --git a/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout12to13Upgrade_FolderPlaceholder.cs b/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout12to13Upgrade_FolderPlaceholder.cs deleted file mode 100644 index 496363404..000000000 --- a/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout12to13Upgrade_FolderPlaceholder.cs +++ /dev/null @@ -1,123 +0,0 @@ -using GVFS.Common; -using GVFS.Common.Database; -using GVFS.Common.FileSystem; -using GVFS.Common.Tracing; -using GVFS.DiskLayoutUpgrades; -using Microsoft.Windows.ProjFS; -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; - -namespace GVFS.Platform.Windows.DiskLayoutUpgrades -{ - public class DiskLayout12to13Upgrade_FolderPlaceholder : DiskLayoutUpgrade.MajorUpgrade - { - protected override int SourceMajorVersion - { - get { return 12; } - } - - /// - /// Adds the folder placeholders to the placeholders list - /// - public override bool TryUpgrade(ITracer tracer, string enlistmentRoot) - { - string dotGVFSRoot = Path.Combine(enlistmentRoot, GVFSPlatform.Instance.Constants.DotGVFSRoot); - try - { - string error; - LegacyPlaceholderListDatabase placeholders; - if (!LegacyPlaceholderListDatabase.TryCreate( - tracer, - Path.Combine(dotGVFSRoot, GVFSConstants.DotGVFS.Databases.PlaceholderList), - new PhysicalFileSystem(), - out placeholders, - out error)) - { - tracer.RelatedError("Failed to open placeholder database: " + error); - return false; - } - - using (placeholders) - { - string workingDirectoryRoot = Path.Combine(enlistmentRoot, GVFSConstants.WorkingDirectoryRootName); - - // Run through the folder placeholders adding to the placeholder list - IEnumerable folderPlaceholderPaths = - GetFolderPlaceholdersFromDisk(tracer, new PhysicalFileSystem(), workingDirectoryRoot) - .Select(x => x.Substring(workingDirectoryRoot.Length + 1)) - .Select(x => new LegacyPlaceholderListDatabase.PlaceholderData(x, GVFSConstants.AllZeroSha)); - - List placeholderEntries = placeholders.GetAllEntries(); - placeholderEntries.AddRange(folderPlaceholderPaths); - - placeholders.WriteAllEntriesAndFlush(placeholderEntries); - } - } - catch (IOException ex) - { - tracer.RelatedError("Could not write to placeholder database: " + ex.ToString()); - return false; - } - catch (Exception ex) - { - tracer.RelatedError("Error updating placeholder database with folders: " + ex.ToString()); - return false; - } - - if (!this.TryIncrementMajorVersion(tracer, enlistmentRoot)) - { - return false; - } - - return true; - } - - private static IEnumerable GetFolderPlaceholdersFromDisk(ITracer tracer, PhysicalFileSystem fileSystem, string path) - { - if (!fileSystem.IsSymLink(path)) - { - foreach (string directory in fileSystem.EnumerateDirectories(path)) - { - if (!directory.EndsWith(Path.DirectorySeparatorChar + GVFSConstants.DotGit.Root)) - { - OnDiskFileState fileState = OnDiskFileState.Full; - if (Utils.TryGetOnDiskFileState(directory, out fileState)) - { - if (IsPlaceholder(fileState)) - { - yield return directory; - } - - // Recurse into placeholders and full folders skipping the tombstones - if (!IsTombstone(fileState)) - { - foreach (string placeholderPath in GetFolderPlaceholdersFromDisk(tracer, fileSystem, directory)) - { - yield return placeholderPath; - } - } - } - else - { - // May cause valid folder placeholders not to be written - // to the placeholder database so we want to error out. - throw new InvalidDataException($"Error getting on disk file state for {directory}"); - } - } - } - } - } - - private static bool IsTombstone(OnDiskFileState fileState) - { - return (fileState & OnDiskFileState.Tombstone) != 0; - } - - private static bool IsPlaceholder(OnDiskFileState fileState) - { - return (fileState & (OnDiskFileState.DirtyPlaceholder | OnDiskFileState.HydratedPlaceholder | OnDiskFileState.Placeholder)) != 0; - } - } -} diff --git a/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout13to14Upgrade_BlobSizes.cs b/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout13to14Upgrade_BlobSizes.cs deleted file mode 100644 index f784bcc87..000000000 --- a/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout13to14Upgrade_BlobSizes.cs +++ /dev/null @@ -1,139 +0,0 @@ -using GVFS.Common; -using GVFS.Common.FileSystem; -using GVFS.Common.Git; -using GVFS.Common.Tracing; -using GVFS.DiskLayoutUpgrades; -using GVFS.Virtualization.BlobSize; -using Microsoft.Isam.Esent; -using Microsoft.Isam.Esent.Collections.Generic; -using System.Collections.Generic; -using System.IO; - -namespace GVFS.Platform.Windows.DiskLayoutUpgrades -{ - public class DiskLayout13to14Upgrade_BlobSizes : DiskLayoutUpgrade.MajorUpgrade - { - private static readonly string BlobSizesName = "BlobSizes"; - - protected override int SourceMajorVersion - { - get { return 13; } - } - - /// - /// Version 13 to 14 added the (shared) SQLite blob sizes database - /// - public override bool TryUpgrade(ITracer tracer, string enlistmentRoot) - { - string dotGVFSPath = Path.Combine(enlistmentRoot, GVFSPlatform.Instance.Constants.DotGVFSRoot); - string error; - if (!RepoMetadata.TryInitialize(tracer, dotGVFSPath, out error)) - { - tracer.RelatedError($"{nameof(DiskLayout13to14Upgrade_BlobSizes)}.{nameof(this.TryUpgrade)}: Could not initialize repo metadata: {error}"); - return false; - } - - string newBlobSizesRoot; - if (!this.TryFindNewBlobSizesRoot(tracer, enlistmentRoot, out newBlobSizesRoot)) - { - return false; - } - - this.MigrateBlobSizes(tracer, enlistmentRoot, newBlobSizesRoot); - - RepoMetadata.Instance.SetBlobSizesRoot(newBlobSizesRoot); - tracer.RelatedInfo("Set BlobSizesRoot: " + newBlobSizesRoot); - - if (!this.TryIncrementMajorVersion(tracer, enlistmentRoot)) - { - return false; - } - - return true; - } - - private bool TryFindNewBlobSizesRoot(ITracer tracer, string enlistmentRoot, out string newBlobSizesRoot) - { - newBlobSizesRoot = null; - - string localCacheRoot; - string error; - if (!RepoMetadata.Instance.TryGetLocalCacheRoot(out localCacheRoot, out error)) - { - tracer.RelatedError($"{nameof(DiskLayout13to14Upgrade_BlobSizes)}.{nameof(this.TryFindNewBlobSizesRoot)}: Could not read local cache root from repo metadata: {error}"); - return false; - } - - if (localCacheRoot == string.Empty) - { - // This is an old repo that was cloned prior to the shared cache - // Blob sizes root should be \.gvfs\databases\blobSizes - newBlobSizesRoot = Path.Combine(enlistmentRoot, GVFSPlatform.Instance.Constants.DotGVFSRoot, GVFSConstants.DotGVFS.Databases.Name, GVFSEnlistment.BlobSizesCacheName); - } - else - { - // This repo was cloned with a shared cache, and the blob sizes should be a sibling to the git objects root - string gitObjectsRoot; - if (!RepoMetadata.Instance.TryGetGitObjectsRoot(out gitObjectsRoot, out error)) - { - tracer.RelatedError($"{nameof(DiskLayout13to14Upgrade_BlobSizes)}.{nameof(this.TryFindNewBlobSizesRoot)}: Could not read git object root from repo metadata: {error}"); - return false; - } - - string cacheRepoFolder = Path.GetDirectoryName(gitObjectsRoot); - newBlobSizesRoot = Path.Combine(cacheRepoFolder, GVFSEnlistment.BlobSizesCacheName); - } - - return true; - } - - private void MigrateBlobSizes(ITracer tracer, string enlistmentRoot, string newBlobSizesRoot) - { - string esentBlobSizeFolder = Path.Combine(enlistmentRoot, GVFSPlatform.Instance.Constants.DotGVFSRoot, BlobSizesName); - PhysicalFileSystem fileSystem = new PhysicalFileSystem(); - if (!fileSystem.DirectoryExists(esentBlobSizeFolder)) - { - tracer.RelatedInfo("Copied no ESENT blob size entries. {0} does not exist", esentBlobSizeFolder); - return; - } - - try - { - using (PersistentDictionary oldBlobSizes = new PersistentDictionary(esentBlobSizeFolder)) - using (BlobSizes newBlobSizes = new BlobSizes(newBlobSizesRoot, fileSystem, tracer)) - { - newBlobSizes.Initialize(); - - int copiedCount = 0; - int totalCount = oldBlobSizes.Count; - foreach (KeyValuePair kvp in oldBlobSizes) - { - Sha1Id sha1; - string error; - if (Sha1Id.TryParse(kvp.Key, out sha1, out error)) - { - newBlobSizes.AddSize(sha1, kvp.Value); - - if (copiedCount++ % 5000 == 0) - { - tracer.RelatedInfo("Copied {0}/{1} ESENT blob size entries", copiedCount, totalCount); - } - } - else - { - tracer.RelatedWarning($"Corrupt entry ({kvp.Key}) found in BlobSizes, skipping. Error: {error}"); - } - } - - newBlobSizes.Flush(); - newBlobSizes.Shutdown(); - tracer.RelatedInfo("Upgrade complete: Copied {0}/{1} ESENT blob size entries", copiedCount, totalCount); - } - } - catch (EsentException ex) - { - tracer.RelatedWarning("BlobSizes appears to be from an older version of GVFS and corrupted, skipping upgrade of blob sizes: " + ex.Message); - } - } - } -} diff --git a/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout7to8Upgrade_NewOperationType.cs b/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout7to8Upgrade_NewOperationType.cs deleted file mode 100644 index d4edabd4e..000000000 --- a/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout7to8Upgrade_NewOperationType.cs +++ /dev/null @@ -1,42 +0,0 @@ -using GVFS.Common; -using GVFS.Common.Tracing; -using GVFS.DiskLayoutUpgrades; -using Microsoft.Isam.Esent; -using Microsoft.Isam.Esent.Collections.Generic; -using System.IO; - -namespace GVFS.Platform.Windows.DiskLayoutUpgrades -{ - public class DiskLayout7to8Upgrade_NewOperationType : DiskLayoutUpgrade.MajorUpgrade - { - protected override int SourceMajorVersion - { - get { return 7; } - } - - /// - /// Version 7 to 8 only added a new value to BackgroundGitUpdate.OperationType, - /// so we only need to bump the ESENT version here. - /// - public override bool TryUpgrade(ITracer tracer, string enlistmentRoot) - { - string dotGVFSRoot = Path.Combine(enlistmentRoot, GVFSPlatform.Instance.Constants.DotGVFSRoot); - string esentRepoMetadata = Path.Combine(dotGVFSRoot, WindowsDiskLayoutUpgradeData.EsentRepoMetadataName); - try - { - using (PersistentDictionary esentMetadata = new PersistentDictionary(esentRepoMetadata)) - { - esentMetadata[WindowsDiskLayoutUpgradeData.DiskLayoutEsentVersionKey] = "8"; - } - } - catch (EsentException ex) - { - tracer.RelatedError("RepoMetadata appears to be from an older version of GVFS and corrupted: " + ex.Message); - return false; - } - - // Do not call TryIncrementDiskLayoutVersion. It updates the flat repo metadata which does not exist yet. - return true; - } - } -} \ No newline at end of file diff --git a/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout8to9Upgrade_RepoMetadataToJson.cs b/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout8to9Upgrade_RepoMetadataToJson.cs deleted file mode 100644 index b86fab6b9..000000000 --- a/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout8to9Upgrade_RepoMetadataToJson.cs +++ /dev/null @@ -1,89 +0,0 @@ -using GVFS.Common; -using GVFS.Common.Tracing; -using GVFS.DiskLayoutUpgrades; -using Microsoft.Isam.Esent; -using Microsoft.Isam.Esent.Collections.Generic; -using System.Collections.Generic; -using System.IO; - -namespace GVFS.Platform.Windows.DiskLayoutUpgrades -{ - public class DiskLayout8to9Upgrade_RepoMetadataToJson : DiskLayoutUpgrade.MajorUpgrade - { - protected override int SourceMajorVersion - { - get { return 8; } - } - - /// - /// Rewrites ESENT RepoMetadata DB to flat JSON file - /// - public override bool TryUpgrade(ITracer tracer, string enlistmentRoot) - { - string dotGVFSRoot = Path.Combine(enlistmentRoot, GVFSPlatform.Instance.Constants.DotGVFSRoot); - if (!this.UpdateRepoMetadata(tracer, dotGVFSRoot)) - { - return false; - } - - if (!this.TryIncrementMajorVersion(tracer, enlistmentRoot)) - { - return false; - } - - return true; - } - - private bool UpdateRepoMetadata(ITracer tracer, string dotGVFSRoot) - { - string esentRepoMetadata = Path.Combine(dotGVFSRoot, WindowsDiskLayoutUpgradeData.EsentRepoMetadataName); - if (Directory.Exists(esentRepoMetadata)) - { - try - { - using (PersistentDictionary oldMetadata = new PersistentDictionary(esentRepoMetadata)) - { - string error; - if (!RepoMetadata.TryInitialize(tracer, dotGVFSRoot, out error)) - { - tracer.RelatedError("Could not initialize RepoMetadata: " + error); - return false; - } - - foreach (KeyValuePair kvp in oldMetadata) - { - tracer.RelatedInfo("Copying ESENT entry: {0} = {1}", kvp.Key, kvp.Value); - RepoMetadata.Instance.SetEntry(kvp.Key, kvp.Value); - } - } - } - catch (IOException ex) - { - tracer.RelatedError("Could not write to new repo metadata: " + ex.Message); - return false; - } - catch (EsentException ex) - { - tracer.RelatedError("RepoMetadata appears to be from an older version of GVFS and corrupted: " + ex.Message); - return false; - } - - string backupName; - if (this.TryRenameFolderForDelete(tracer, esentRepoMetadata, out backupName)) - { - // If this fails, we leave behind cruft, but there's no harm because we renamed. - this.TryDeleteFolder(tracer, backupName); - return true; - } - else - { - // To avoid double upgrading, we should rollback if we can't rename the old data - this.TryDeleteFile(tracer, RepoMetadata.Instance.DataFilePath); - return false; - } - } - - return true; - } - } -} \ No newline at end of file diff --git a/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout9to10Upgrade_BackgroundAndPlaceholderListToFileBased.cs b/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout9to10Upgrade_BackgroundAndPlaceholderListToFileBased.cs deleted file mode 100644 index 9b0b84b12..000000000 --- a/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/DiskLayout9to10Upgrade_BackgroundAndPlaceholderListToFileBased.cs +++ /dev/null @@ -1,181 +0,0 @@ -using GVFS.Common; -using GVFS.Common.FileSystem; -using GVFS.Common.Tracing; -using GVFS.DiskLayoutUpgrades; -using GVFS.GVFlt; -using GVFS.Virtualization.Background; -using Microsoft.Isam.Esent; -using Microsoft.Isam.Esent.Collections.Generic; -using System.Collections.Generic; -using System.IO; - -namespace GVFS.Platform.Windows.DiskLayoutUpgrades -{ - public class DiskLayout9to10Upgrade_BackgroundAndPlaceholderListToFileBased : DiskLayoutUpgrade.MajorUpgrade - { - private const string EsentBackgroundOpsFolder = "BackgroundGitUpdates"; - private const string EsentPlaceholderListFolder = "PlaceholderList"; - - protected override int SourceMajorVersion - { - get { return 9; } - } - - /// - /// Rewrites ESENT BackgroundGitUpdates and PlaceholderList DBs to flat formats - /// - public override bool TryUpgrade(ITracer tracer, string enlistmentRoot) - { - string dotGVFSRoot = Path.Combine(enlistmentRoot, GVFSPlatform.Instance.Constants.DotGVFSRoot); - if (!this.UpdateBackgroundOperations(tracer, dotGVFSRoot)) - { - return false; - } - - if (!this.UpdatePlaceholderList(tracer, dotGVFSRoot)) - { - return false; - } - - if (!this.TryIncrementMajorVersion(tracer, enlistmentRoot)) - { - return false; - } - - return true; - } - - private bool UpdatePlaceholderList(ITracer tracer, string dotGVFSRoot) - { - string esentPlaceholderFolder = Path.Combine(dotGVFSRoot, EsentPlaceholderListFolder); - if (Directory.Exists(esentPlaceholderFolder)) - { - string newPlaceholderFolder = Path.Combine(dotGVFSRoot, GVFSConstants.DotGVFS.Databases.PlaceholderList); - try - { - using (PersistentDictionary oldPlaceholders = - new PersistentDictionary(esentPlaceholderFolder)) - { - string error; - LegacyPlaceholderListDatabase newPlaceholders; - if (!LegacyPlaceholderListDatabase.TryCreate( - tracer, - newPlaceholderFolder, - new PhysicalFileSystem(), - out newPlaceholders, - out error)) - { - tracer.RelatedError("Failed to create new placeholder database: " + error); - return false; - } - - using (newPlaceholders) - { - List data = new List(); - foreach (KeyValuePair kvp in oldPlaceholders) - { - tracer.RelatedInfo("Copying ESENT entry: {0} = {1}", kvp.Key, kvp.Value); - data.Add(new LegacyPlaceholderListDatabase.PlaceholderData(path: kvp.Key, fileShaOrFolderValue: kvp.Value)); - } - - newPlaceholders.WriteAllEntriesAndFlush(data); - } - } - } - catch (IOException ex) - { - tracer.RelatedError("Could not write to new placeholder database: " + ex.Message); - return false; - } - catch (EsentException ex) - { - tracer.RelatedError("Placeholder database appears to be from an older version of GVFS and corrupted: " + ex.Message); - return false; - } - - string backupName; - if (this.TryRenameFolderForDelete(tracer, esentPlaceholderFolder, out backupName)) - { - // If this fails, we leave behind cruft, but there's no harm because we renamed. - this.TryDeleteFolder(tracer, backupName); - return true; - } - else - { - // To avoid double upgrading, we should rollback if we can't rename the old data - this.TryDeleteFile(tracer, RepoMetadata.Instance.DataFilePath); - return false; - } - } - - return true; - } - - private bool UpdateBackgroundOperations(ITracer tracer, string dotGVFSRoot) - { - string esentBackgroundOpsFolder = Path.Combine(dotGVFSRoot, EsentBackgroundOpsFolder); - if (Directory.Exists(esentBackgroundOpsFolder)) - { - string newBackgroundOpsFolder = Path.Combine(dotGVFSRoot, GVFSConstants.DotGVFS.Databases.BackgroundFileSystemTasks); - try - { - using (PersistentDictionary oldBackgroundOps = - new PersistentDictionary(esentBackgroundOpsFolder)) - { - string error; - FileSystemTaskQueue newBackgroundOps; - if (!FileSystemTaskQueue.TryCreate( - tracer, - newBackgroundOpsFolder, - new PhysicalFileSystem(), - out newBackgroundOps, - out error)) - { - tracer.RelatedError("Failed to create new background operations folder: " + error); - return false; - } - - using (newBackgroundOps) - { - foreach (KeyValuePair kvp in oldBackgroundOps) - { - tracer.RelatedInfo("Copying ESENT entry: {0} = {1}", kvp.Key, kvp.Value); - newBackgroundOps.EnqueueAndFlush( - new FileSystemTask( - (FileSystemTask.OperationType)kvp.Value.Operation, - kvp.Value.VirtualPath, - kvp.Value.OldVirtualPath)); - } - } - } - } - catch (IOException ex) - { - tracer.RelatedError("Could not write to new background operations: " + ex.Message); - return false; - } - catch (EsentException ex) - { - tracer.RelatedError("BackgroundOperations appears to be from an older version of GVFS and corrupted: " + ex.Message); - return false; - } - - string backupName; - if (this.TryRenameFolderForDelete(tracer, esentBackgroundOpsFolder, out backupName)) - { - // If this fails, we leave behind cruft, but there's no harm because we renamed. - this.TryDeleteFolder(tracer, backupName); - return true; - } - else - { - // To avoid double upgrading, we should rollback if we can't rename the old data - this.TryDeleteFile(tracer, RepoMetadata.Instance.DataFilePath); - return false; - } - } - - return true; - } - } -} diff --git a/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/WindowsDiskLayoutUpgradeData.cs b/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/WindowsDiskLayoutUpgradeData.cs index d79e29594..6fdab6c91 100644 --- a/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/WindowsDiskLayoutUpgradeData.cs +++ b/GVFS/GVFS.Platform.Windows/DiskLayoutUpgrades/WindowsDiskLayoutUpgradeData.cs @@ -1,30 +1,16 @@ using GVFS.Common; using GVFS.DiskLayoutUpgrades; -using Microsoft.Isam.Esent.Collections.Generic; -using System; -using System.IO; namespace GVFS.Platform.Windows.DiskLayoutUpgrades { public class WindowsDiskLayoutUpgradeData : IDiskLayoutUpgradeData { - public const string DiskLayoutEsentVersionKey = "DiskLayoutVersion"; - public const string EsentRepoMetadataName = "RepoMetadata"; - public DiskLayoutUpgrade[] Upgrades { get { return new DiskLayoutUpgrade[] { - new DiskLayout7to8Upgrade_NewOperationType(), - new DiskLayout8to9Upgrade_RepoMetadataToJson(), - new DiskLayout9to10Upgrade_BackgroundAndPlaceholderListToFileBased(), - new DiskLayout10to11Upgrade_NewOperationType(), - new DiskLayout11to12Upgrade_SharedLocalCache(), - new DiskLayout12_0To12_1Upgrade_StatusAheadBehind(), - new DiskLayout12to13Upgrade_FolderPlaceholder(), - new DiskLayout13to14Upgrade_BlobSizes(), new DiskLayout14to15Upgrade_ModifiedPaths(), new DiskLayout15to16Upgrade_GitStatusCache(), new DiskLayout16to17Upgrade_FolderPlaceholderValues(), @@ -37,36 +23,12 @@ public DiskLayoutUpgrade[] Upgrades public DiskLayoutVersion Version => new DiskLayoutVersion( currentMajorVersion: 19, currentMinorVersion: 0, - minimumSupportedMajorVersion: 7); + minimumSupportedMajorVersion: 14); public bool TryParseLegacyDiskLayoutVersion(string dotGVFSPath, out int majorVersion) { - string repoMetadataPath = Path.Combine(dotGVFSPath, EsentRepoMetadataName); majorVersion = 0; - if (Directory.Exists(repoMetadataPath)) - { - try - { - using (PersistentDictionary oldMetadata = new PersistentDictionary(repoMetadataPath)) - { - string versionString = oldMetadata[DiskLayoutEsentVersionKey]; - if (!int.TryParse(versionString, out majorVersion)) - { - return false; - } - } - } - catch - { - return false; - } - } - else - { - return false; - } - - return true; + return false; } } } diff --git a/GVFS/GVFS.Platform.Windows/GVFS.Platform.Windows.csproj b/GVFS/GVFS.Platform.Windows/GVFS.Platform.Windows.csproj index f8fb56597..9ee218047 100644 --- a/GVFS/GVFS.Platform.Windows/GVFS.Platform.Windows.csproj +++ b/GVFS/GVFS.Platform.Windows/GVFS.Platform.Windows.csproj @@ -1,24 +1,16 @@ - net471 - - - - - - - - - - + + + diff --git a/GVFS/GVFS.Platform.Windows/WindowsFileSystem.cs b/GVFS/GVFS.Platform.Windows/WindowsFileSystem.cs index b79f1b3e5..7ba732a13 100644 --- a/GVFS/GVFS.Platform.Windows/WindowsFileSystem.cs +++ b/GVFS/GVFS.Platform.Windows/WindowsFileSystem.cs @@ -1,10 +1,8 @@ using GVFS.Common; using GVFS.Common.FileSystem; using GVFS.Common.Tracing; -using Microsoft.Win32.SafeHandles; using System; using System.IO; -using System.Runtime.InteropServices; using System.Security.AccessControl; using System.Security.Principal; @@ -106,9 +104,53 @@ public bool TryGetNormalizedPath(string path, out string normalizedPath, out str return WindowsFileSystem.TryGetNormalizedPathImplementation(path, out normalizedPath, out errorMessage); } + /// + /// Hydrates a file by reading its first byte, triggering ProjFS placeholder hydration. + /// + /// + /// This was originally implemented using direct P/Invoke to kernel32 CreateFile/ReadFile + /// for minimal overhead. During the .NET 10 NativeAOT migration, the P/Invoke path caused + /// intermittent ACCESS_VIOLATION (0xC0000005) crashes under high concurrency in the + /// HydrateFilesStage pipeline. The P/Invoke declarations also had incorrect parameter types + /// (uint/int for pointer-sized params like LPSECURITY_ATTRIBUTES and LPOVERLAPPED). + /// + /// Replaced with managed FileStream, which internally calls the same Win32 APIs through the + /// runtime's own NativeAOT-validated interop layer. Benchmarked at equivalent throughput + /// (~36-40K files/s) in the multi-threaded scenario that matches actual HydrateFilesStage + /// usage (ProcessorCount * 2 threads). + /// public bool HydrateFile(string fileName, byte[] buffer) { - return NativeFileReader.TryReadFirstByteOfFile(fileName, buffer); + if (buffer.Length < 1) + { + throw new ArgumentException("Buffer must be at least 1 byte.", nameof(buffer)); + } + + try + { + using (FileStream fs = new FileStream( + fileName, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete)) + { + // Read is intentionally inexact — we only need to trigger ProjFS hydration, + // not verify byte count. Empty files (0 bytes read) are fine. +#pragma warning disable CA2022 + fs.Read(buffer, 0, 1); +#pragma warning restore CA2022 + } + + return true; + } + catch (IOException) + { + return false; + } + catch (UnauthorizedAccessException) + { + return false; + } } public bool IsExecutable(string fileName) @@ -165,7 +207,8 @@ public bool TryCreateDirectoryAccessibleByAuthUsers(string directoryPath, out st // Use AccessRuleFactory rather than creating a FileSystemAccessRule because the NativeMethods.FileAccess flags // we're specifying are not valid for the FileSystemRights parameter of the FileSystemAccessRule constructor - DirectorySecurity directorySecurity = Directory.GetAccessControl(directoryPath); + DirectoryInfo directoryInfo = new DirectoryInfo(directoryPath); + DirectorySecurity directorySecurity = directoryInfo.GetAccessControl(); AccessRule authenticatedUsersAccessRule = directorySecurity.AccessRuleFactory( new SecurityIdentifier(WellKnownSidType.AuthenticatedUserSid, null), unchecked((int)(NativeMethods.FileAccess.DELETE | NativeMethods.FileAccess.GENERIC_EXECUTE | NativeMethods.FileAccess.GENERIC_WRITE | NativeMethods.FileAccess.GENERIC_READ)), @@ -177,7 +220,7 @@ public bool TryCreateDirectoryAccessibleByAuthUsers(string directoryPath, out st // The return type of the AccessRuleFactory method is the base class, AccessRule, but the return value can be cast safely to the derived class. // https://msdn.microsoft.com/en-us/library/system.security.accesscontrol.filesystemsecurity.accessrulefactory(v=vs.110).aspx directorySecurity.AddAccessRule((FileSystemAccessRule)authenticatedUsersAccessRule); - Directory.SetAccessControl(directoryPath, directorySecurity); + directoryInfo.SetAccessControl(directorySecurity); } catch (Exception e) when (e is IOException || e is UnauthorizedAccessException || e is SystemException) { @@ -210,7 +253,7 @@ public bool TryCreateDirectoryWithAdminAndUserModifyPermissions(string directory AddUsersAccessRulesToDirectorySecurity(directorySecurity, grantUsersModifyPermissions: true); AddAdminAccessRulesToDirectorySecurity(directorySecurity); - Directory.CreateDirectory(directoryPath, directorySecurity); + directorySecurity.CreateDirectory(directoryPath); } catch (Exception e) when (e is IOException || e is UnauthorizedAccessException || @@ -229,10 +272,11 @@ public bool TryCreateOrUpdateDirectoryToAdminModifyPermissions(ITracer tracer, s { try { + DirectoryInfo directoryInfo = new DirectoryInfo(directoryPath); DirectorySecurity directorySecurity; if (Directory.Exists(directoryPath)) { - directorySecurity = Directory.GetAccessControl(directoryPath); + directorySecurity = directoryInfo.GetAccessControl(); } else { @@ -247,10 +291,10 @@ public bool TryCreateOrUpdateDirectoryToAdminModifyPermissions(ITracer tracer, s AddUsersAccessRulesToDirectorySecurity(directorySecurity, grantUsersModifyPermissions: false); AddAdminAccessRulesToDirectorySecurity(directorySecurity); - Directory.CreateDirectory(directoryPath, directorySecurity); + directorySecurity.CreateDirectory(directoryPath); // Ensure the ACLs are set correctly if the directory already existed - Directory.SetAccessControl(directoryPath, directorySecurity); + directoryInfo.SetAccessControl(directorySecurity); } catch (Exception e) when (e is IOException || e is SystemException) { @@ -289,63 +333,16 @@ public void EnsureDirectoryIsOwnedByCurrentUser(string directoryPath) // Ensure directory exists, inheriting all other ACLS Directory.CreateDirectory(directoryPath); // If the user is currently elevated, the owner of the directory will be the Administrators group. - DirectorySecurity directorySecurity = Directory.GetAccessControl(directoryPath); + DirectoryInfo directoryInfo = new DirectoryInfo(directoryPath); + DirectorySecurity directorySecurity = directoryInfo.GetAccessControl(); IdentityReference directoryOwner = directorySecurity.GetOwner(typeof(SecurityIdentifier)); SecurityIdentifier administratorsSid = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null); if (directoryOwner == administratorsSid) { WindowsIdentity currentUser = WindowsIdentity.GetCurrent(); directorySecurity.SetOwner(currentUser.User); - Directory.SetAccessControl(directoryPath, directorySecurity); + directoryInfo.SetAccessControl(directorySecurity); } } - - private class NativeFileReader - { - private const uint GenericRead = 0x80000000; - private const uint OpenExisting = 3; - - public static bool TryReadFirstByteOfFile(string fileName, byte[] buffer) - { - using (SafeFileHandle handle = Open(fileName)) - { - if (!handle.IsInvalid) - { - return ReadOneByte(handle, buffer); - } - } - - return false; - } - - private static SafeFileHandle Open(string fileName) - { - return CreateFile(fileName, GenericRead, (uint)(FileShare.ReadWrite | FileShare.Delete), 0, OpenExisting, 0, 0); - } - - private static bool ReadOneByte(SafeFileHandle handle, byte[] buffer) - { - int bytesRead = 0; - return ReadFile(handle, buffer, 1, ref bytesRead, 0); - } - - [DllImport("kernel32", SetLastError = true, ThrowOnUnmappableChar = true, CharSet = CharSet.Unicode)] - private static extern SafeFileHandle CreateFile( - string fileName, - uint desiredAccess, - uint shareMode, - uint securityAttributes, - uint creationDisposition, - uint flagsAndAttributes, - int hemplateFile); - - [DllImport("kernel32", SetLastError = true)] - private static extern bool ReadFile( - SafeFileHandle file, - [Out] byte[] buffer, - int numberOfBytesToRead, - ref int numberOfBytesRead, - int overlapped); - } } } diff --git a/GVFS/GVFS.Platform.Windows/WindowsPhysicalDiskInfo.cs b/GVFS/GVFS.Platform.Windows/WindowsPhysicalDiskInfo.cs index 8debab0c0..e417f8369 100644 --- a/GVFS/GVFS.Platform.Windows/WindowsPhysicalDiskInfo.cs +++ b/GVFS/GVFS.Platform.Windows/WindowsPhysicalDiskInfo.cs @@ -1,16 +1,32 @@ using System; using System.Collections.Generic; using System.IO; -using System.Linq; -using System.Management; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; namespace GVFS.Platform.Windows { + /// + /// Collects physical disk telemetry using P/Invoke (kernel32 + DeviceIoControl) + /// instead of System.Management/WMI, which requires COM interop incompatible + /// with NativeAOT. + /// public class WindowsPhysicalDiskInfo { + private static readonly Dictionary MapDriveType = new Dictionary() + { + { 0, "unknown" }, + { 1, "InvalidRootPath" }, + { 2, "Removable" }, + { 3, "Fixed" }, + { 4, "Remote" }, + { 5, "CDROM" }, + { 6, "RAMDisk" }, + }; + private static readonly Dictionary MapBusType = new Dictionary() { - { 0, "unknwon" }, + { 0, "unknown" }, { 1, "SCSI" }, { 2, "ATAPI" }, { 3, "ATA" }, @@ -30,144 +46,341 @@ public class WindowsPhysicalDiskInfo { 17, "NVMe" }, }; - private static readonly Dictionary MapMediaType = new Dictionary() - { - { 0, "unspecified" }, - { 3, "HDD" }, - { 4, "SSD" }, - { 5, "SCM" }, - }; + #region P/Invoke constants + + private const uint FILE_SHARE_READ = 0x00000001; + private const uint FILE_SHARE_WRITE = 0x00000002; + private const uint OPEN_EXISTING = 3; + + private const uint IOCTL_VOLUME_GET_VOLUME_DISK_EXTENTS = 0x00560000; + private const uint IOCTL_STORAGE_QUERY_PROPERTY = 0x002D1400; + + private const int StorageAdapterProperty = 1; + private const int StorageDeviceSeekPenaltyProperty = 7; + + private const int PropertyStandardQuery = 0; + + #endregion + + #region P/Invoke declarations + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern uint GetDriveType(string lpRootPathName); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool GetVolumeInformation( + string lpRootPathName, + char[] lpVolumeNameBuffer, + int nVolumeNameSize, + out uint lpVolumeSerialNumber, + out uint lpMaximumComponentLength, + out uint lpFileSystemFlags, + char[] lpFileSystemNameBuffer, + int nFileSystemNameSize); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool GetDiskFreeSpaceEx( + string lpDirectoryName, + out ulong lpFreeBytesAvailableToCaller, + out ulong lpTotalNumberOfBytes, + out ulong lpTotalNumberOfFreeBytes); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFile( + string lpFileName, + uint dwDesiredAccess, + uint dwShareMode, + IntPtr lpSecurityAttributes, + uint dwCreationDisposition, + uint dwFlagsAndAttributes, + IntPtr hTemplateFile); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool DeviceIoControl( + SafeFileHandle hDevice, + uint dwIoControlCode, + ref StoragePropertyQuery lpInBuffer, + int nInBufferSize, + IntPtr lpOutBuffer, + int nOutBufferSize, + out int lpBytesReturned, + IntPtr lpOverlapped); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool DeviceIoControl( + SafeFileHandle hDevice, + uint dwIoControlCode, + IntPtr lpInBuffer, + int nInBufferSize, + IntPtr lpOutBuffer, + int nOutBufferSize, + out int lpBytesReturned, + IntPtr lpOverlapped); + + #endregion - private static readonly Dictionary MapDriveType = new Dictionary() + #region Native structs + + [StructLayout(LayoutKind.Sequential)] + private struct StoragePropertyQuery { - { 0, "unknown" }, - { 1, "InvalidRootPath" }, - { 2, "Removable" }, - { 3, "Fixed" }, - { 4, "Remote" }, - { 5, "CDROM" }, - { 6, "RAMDisk" }, - }; + public int PropertyId; + public int QueryType; + public byte AdditionalParameters; + } + + #endregion /// /// Get the properties of the drive/volume/partition/physical disk associated - /// the given pathname. For example, whether the drive is an SSD or HDD. + /// with the given pathname. For example, whether the drive is an SSD or HDD. + /// + /// Uses direct P/Invoke calls (GetDriveType, GetVolumeInformation, + /// GetDiskFreeSpaceEx, DeviceIoControl) instead of WMI so the code is + /// compatible with NativeAOT compilation. /// /// A dictionary of platform-specific keywords and values. public static Dictionary GetPhysicalDiskInfo(string path, bool sizeStatsOnly) { - // Use the WMI APIs to get details about the physical disk associated with the given path. - // Some of these fields are avilable using normal classes, such as System.IO.DriveInfo: - // https://msdn.microsoft.com/en-us/library/system.io.driveinfo(v=vs.110).aspx - // - // But the lower-level fields, such as the BusType and SpindleSpeed, are not. - // - // MSFT_Partition: - // https://msdn.microsoft.com/en-us/library/windows/desktop/hh830524(v=vs.85).aspx - // - // MSFT_Disk: - // https://msdn.microsoft.com/en-us/library/windows/desktop/hh830493(v=vs.85).aspx - // - // MSFT_Volume: - // https://msdn.microsoft.com/en-us/library/windows/desktop/hh830604(v=vs.85).aspx - // - // MSFT_PhysicalDisk: - // https://msdn.microsoft.com/en-us/library/windows/desktop/hh830532(v=vs.85) - // - // An overview of these "classes" can be found here: - // https://msdn.microsoft.com/en-us/library/hh830612.aspx - // - // The map variables defined above are based on property values documented in one of the above APIs. - // There are helper functions below to convert from ManagementBaseObject values into the map values. - // These do not do strict validation because the OS can add new values at any time. For example, the - // integer code for NVMe bus drives was recently added. If an unrecognized value is received, the - // raw integer value is used untranslated. - // - // They are accessed via a generic WQL language that is similar to SQL. See here for an example: - // https://blogs.technet.microsoft.com/josebda/2014/08/11/sample-c-code-for-using-the-latest-wmi-classes-to-manage-windows-storage/ - Dictionary result = new Dictionary(); try { char driveLetter = PathToDriveLetter(path); - result.Add("DriveLetter", driveLetter.ToString()); + result["DriveLetter"] = driveLetter.ToString(); + + string rootPath = $"{driveLetter}:\\"; - ManagementScope scope = new ManagementScope(@"\\.\root\microsoft\windows\storage"); - scope.Connect(); + uint driveType = GetDriveType(rootPath); + result["VolumeDriveType"] = MapDriveType.TryGetValue(driveType, out string dtName) + ? dtName + : driveType.ToString(); - DiskSizeStatistics(scope, driveLetter, ref result); + CollectVolumeInfo(rootPath, result); + CollectVolumeSizeInfo(rootPath, result); if (sizeStatsOnly) { return result; } - DiskTypeInfo(scope, driveLetter, ref result); + CollectPhysicalDiskProperties(driveLetter, result); } catch (Exception e) { - result.Add("Error", e.Message); + result["Error"] = e.Message; } return result; } - private static void DiskSizeStatistics(ManagementScope scope, char driveLetter, ref Dictionary result) + private static void CollectVolumeInfo(string rootPath, Dictionary result) { - string queryVolumeString = $"SELECT DriveType,FileSystem,FileSystemLabel,Size,SizeRemaining FROM MSFT_Volume WHERE DriveLetter=\"{driveLetter}\""; - ManagementBaseObject mbo = GetFirstRecord(scope, queryVolumeString); - if (mbo != null) + char[] volumeLabel = new char[261]; + char[] fileSystemName = new char[261]; + + if (GetVolumeInformation( + rootPath, + volumeLabel, + volumeLabel.Length, + out _, + out _, + out _, + fileSystemName, + fileSystemName.Length)) + { + result["VolumeFileSystem"] = new string(fileSystemName).TrimEnd('\0'); + result["VolumeFileSystemLabel"] = new string(volumeLabel).TrimEnd('\0'); + } + else { - result.Add("VolumeDriveType", GetMapValue(MapDriveType, FetchValue(mbo, "DriveType"))); - result.Add("VolumeFileSystem", FetchValue(mbo, "FileSystem")); - result.Add("VolumeFileSystemLabel", FetchValue(mbo, "FileSystemLabel")); - result.Add("VolumeSize", FetchValue(mbo, "Size")); - result.Add("VolumeSizeRemaining", FetchValue(mbo, "SizeRemaining")); + result["VolumeFileSystem"] = "unknown"; + result["VolumeFileSystemLabel"] = "unknown"; } } - private static void DiskTypeInfo(ManagementScope scope, char driveLetter, ref Dictionary result) + private static void CollectVolumeSizeInfo(string rootPath, Dictionary result) { - string queryPartitionString = $"SELECT DiskNumber FROM MSFT_Partition WHERE DriveLetter=\"{driveLetter}\""; - ManagementBaseObject mbo = GetFirstRecord(scope, queryPartitionString); - if (mbo != null) + if (GetDiskFreeSpaceEx(rootPath, out _, out ulong totalBytes, out ulong freeBytes)) { - string diskNumber = FetchValue(mbo, "DiskNumber"); - result.Add("DiskNumber", diskNumber); + result["VolumeSize"] = totalBytes.ToString(); + result["VolumeSizeRemaining"] = freeBytes.ToString(); + } + else + { + result["VolumeSize"] = "unknown"; + result["VolumeSizeRemaining"] = "unknown"; + } + } - if (diskNumber.Length > 0) - { - string queryDiskString = $"SELECT Model,IsBoot,IsSystem,SerialNumber FROM MSFT_Disk WHERE Number=\"{diskNumber}\""; - mbo = GetFirstRecord(scope, queryDiskString); - if (mbo != null) - { - result.Add("DiskModel", FetchValue(mbo, "Model")); - result.Add("DiskIsSystem", FetchValue(mbo, "IsSystem")); - result.Add("DiskIsBoot", FetchValue(mbo, "IsBoot")); - result.Add("DiskSerialNumber", FetchValue(mbo, "SerialNumber")); - } + /// + /// Opens the volume handle, resolves the physical disk number via + /// IOCTL_VOLUME_GET_VOLUME_DISK_EXTENTS, then queries the physical disk + /// for seek-penalty (SSD vs HDD) and bus type via IOCTL_STORAGE_QUERY_PROPERTY. + /// + private static void CollectPhysicalDiskProperties(char driveLetter, Dictionary result) + { + string volumePath = $@"\\.\{driveLetter}:"; + using SafeFileHandle volumeHandle = CreateFile( + volumePath, + 0, + FILE_SHARE_READ | FILE_SHARE_WRITE, + IntPtr.Zero, + OPEN_EXISTING, + 0, + IntPtr.Zero); + + if (volumeHandle.IsInvalid) + { + result["DiskNumber"] = "unknown"; + result["PhysicalMediaType"] = "unknown"; + result["PhysicalBusType"] = "unknown"; + return; + } + + int diskNumber = GetDiskNumberFromVolume(volumeHandle); + if (diskNumber < 0) + { + result["DiskNumber"] = "unknown"; + result["PhysicalMediaType"] = "unknown"; + result["PhysicalBusType"] = "unknown"; + return; + } + + result["DiskNumber"] = diskNumber.ToString(); + + string diskPath = $@"\\.\PhysicalDrive{diskNumber}"; + using SafeFileHandle diskHandle = CreateFile( + diskPath, + 0, + FILE_SHARE_READ | FILE_SHARE_WRITE, + IntPtr.Zero, + OPEN_EXISTING, + 0, + IntPtr.Zero); + + if (diskHandle.IsInvalid) + { + result["PhysicalMediaType"] = "unknown"; + result["PhysicalBusType"] = "unknown"; + return; + } + + result["PhysicalMediaType"] = QueryMediaType(diskHandle); + result["PhysicalBusType"] = QueryBusType(diskHandle); + } - string queryPhysicalDiskString = $"SELECT MediaType,BusType,SpindleSpeed FROM MSFT_PhysicalDisk WHERE DeviceId=\"{diskNumber}\""; - mbo = GetFirstRecord(scope, queryPhysicalDiskString); - if (mbo != null) + /// + /// Uses IOCTL_VOLUME_GET_VOLUME_DISK_EXTENTS to determine which physical + /// disk number backs the given volume. + /// + private static int GetDiskNumberFromVolume(SafeFileHandle volumeHandle) + { + const int bufferSize = 256; + IntPtr buffer = Marshal.AllocHGlobal(bufferSize); + try + { + if (DeviceIoControl( + volumeHandle, + IOCTL_VOLUME_GET_VOLUME_DISK_EXTENTS, + IntPtr.Zero, + 0, + buffer, + bufferSize, + out _, + IntPtr.Zero)) + { + int count = Marshal.ReadInt32(buffer, 0); + if (count > 0) { - result.Add("PhysicalMediaType", GetMapValue(MapMediaType, FetchValue(mbo, "MediaType"))); - result.Add("PhysicalBusType", GetMapValue(MapBusType, FetchValue(mbo, "BusType"))); - result.Add("PhysicalSpindleSpeed", FetchValue(mbo, "SpindleSpeed")); + return Marshal.ReadInt32(buffer, 8); } } + + return -1; + } + finally + { + Marshal.FreeHGlobal(buffer); } } - private static string FetchValue(ManagementBaseObject mbo, string key) + /// + /// Queries StorageDeviceSeekPenaltyProperty via DeviceIoControl. + /// No seek penalty means SSD; seek penalty means HDD. + /// + private static string QueryMediaType(SafeFileHandle diskHandle) { - return (mbo[key] != null) ? mbo[key].ToString().Trim() : string.Empty; + StoragePropertyQuery query = new StoragePropertyQuery + { + PropertyId = StorageDeviceSeekPenaltyProperty, + QueryType = PropertyStandardQuery, + }; + + const int outSize = 32; + IntPtr buffer = Marshal.AllocHGlobal(outSize); + try + { + if (DeviceIoControl( + diskHandle, + IOCTL_STORAGE_QUERY_PROPERTY, + ref query, + Marshal.SizeOf(), + buffer, + outSize, + out _, + IntPtr.Zero)) + { + byte penalty = Marshal.ReadByte(buffer, 8); + return penalty != 0 ? "HDD" : "SSD"; + } + + return "unknown"; + } + finally + { + Marshal.FreeHGlobal(buffer); + } } - private static string GetMapValue(Dictionary map, string rawValue) + /// + /// Queries StorageAdapterProperty via DeviceIoControl to read the + /// STORAGE_BUS_TYPE from the STORAGE_ADAPTER_DESCRIPTOR. + /// + private static string QueryBusType(SafeFileHandle diskHandle) { - return int.TryParse(rawValue, out int key) && map.Keys.Contains(key) ? map[key] : rawValue; + StoragePropertyQuery query = new StoragePropertyQuery + { + PropertyId = StorageAdapterProperty, + QueryType = PropertyStandardQuery, + }; + + const int outSize = 256; + IntPtr buffer = Marshal.AllocHGlobal(outSize); + try + { + if (DeviceIoControl( + diskHandle, + IOCTL_STORAGE_QUERY_PROPERTY, + ref query, + Marshal.SizeOf(), + buffer, + outSize, + out _, + IntPtr.Zero)) + { + int busType = Marshal.ReadByte(buffer, 24); + return MapBusType.TryGetValue(busType, out string busName) + ? busName + : busType.ToString(); + } + + return "unknown"; + } + finally + { + Marshal.FreeHGlobal(buffer); + } } private static char PathToDriveLetter(string path) @@ -187,18 +400,7 @@ private static char PathToDriveLetter(string path) } } - // A bogus path or a UNC path. This should not happen since the path should already - // have been validated. throw new ArgumentException($"Could not map path '{path}' to a drive letter."); } - - private static ManagementBaseObject GetFirstRecord(ManagementScope scope, string queryString) - { - ObjectQuery q = new ObjectQuery(queryString); - ManagementObjectSearcher s = new ManagementObjectSearcher(scope, q); - - // Only return the first result. (There should only be one row returned for each of these queries.) - return s.Get().Cast().FirstOrDefault(); - } } } diff --git a/GVFS/GVFS.Platform.Windows/WindowsPlatform.cs b/GVFS/GVFS.Platform.Windows/WindowsPlatform.cs index ecd050535..b2f0b4533 100644 --- a/GVFS/GVFS.Platform.Windows/WindowsPlatform.cs +++ b/GVFS/GVFS.Platform.Windows/WindowsPlatform.cs @@ -144,6 +144,13 @@ public override void StartBackgroundVFS4GProcess(ITracer tracer, string programN { programArguments = string.Join(" ", args.Select(arg => arg.Contains(' ') ? "\"" + arg + "\"" : arg)); ProcessStartInfo processInfo = new ProcessStartInfo(programName, programArguments); + + // UseShellExecute=true uses ShellExecuteEx which does NOT inherit + // the parent's handles. This is critical: without it, the background + // mount process inherits the parent's redirected stdout pipe handle, + // causing callers' Process.StandardOutput.ReadToEnd() to hang forever + // (the pipe never closes because the mount daemon holds a copy). + processInfo.UseShellExecute = true; processInfo.WindowStyle = ProcessWindowStyle.Hidden; Process executingProcess = new Process(); @@ -173,7 +180,7 @@ public override NamedPipeServerStream CreatePipeByName(string pipeName) security.AddAccessRule(new PipeAccessRule(new SecurityIdentifier(WellKnownSidType.CreatorOwnerSid, null), PipeAccessRights.FullControl, AccessControlType.Allow)); security.AddAccessRule(new PipeAccessRule(new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null), PipeAccessRights.FullControl, AccessControlType.Allow)); - NamedPipeServerStream pipe = new NamedPipeServerStream( + NamedPipeServerStream pipe = NamedPipeServerStreamAcl.Create( pipeName, PipeDirection.InOut, NamedPipeServerStream.MaxAllowedServerInstances, diff --git a/GVFS/GVFS.PostIndexChangedHook/main.cpp b/GVFS/GVFS.PostIndexChangedHook/main.cpp index 03fb26b29..9b71e2495 100644 --- a/GVFS/GVFS.PostIndexChangedHook/main.cpp +++ b/GVFS/GVFS.PostIndexChangedHook/main.cpp @@ -1,4 +1,4 @@ -#include "stdafx.h" +#include "stdafx.h" #include "common.h" enum PostIndexChangedErrorReturnCode @@ -8,6 +8,66 @@ enum PostIndexChangedErrorReturnCode const int PIPE_BUFFER_SIZE = 1024; +// Returns true if GIT_INDEX_FILE refers to a non-canonical (temp) index. +// The canonical index path is $GIT_DIR/index; anything else is a temp +// index that GVFS doesn't need to be notified about. +// +// GIT_DIR is always set by git.exe itself (via xsetenv in setup.c) before +// any hook runs, so it is reliably present. GIT_INDEX_FILE is only present +// when an external caller (script, build tool, etc.) explicitly exports it +// before invoking git, to redirect index operations to a temp file. +static bool IsNonCanonicalIndex() +{ + char *indexFileEnv = NULL; + size_t indexLen = 0; + _dupenv_s(&indexFileEnv, &indexLen, "GIT_INDEX_FILE"); + + if (indexFileEnv == NULL || indexFileEnv[0] == '\0') + { + free(indexFileEnv); + return false; + } + + char *gitDirEnv = NULL; + size_t gitDirLen = 0; + _dupenv_s(&gitDirEnv, &gitDirLen, "GIT_DIR"); + + if (gitDirEnv == NULL || gitDirEnv[0] == '\0') + { + // GIT_INDEX_FILE is set but GIT_DIR is not — shouldn't happen + // inside a hook (git.exe always sets GIT_DIR), but err on the + // side of correctness: proceed with the notification. + free(indexFileEnv); + free(gitDirEnv); + return false; + } + + // Build the canonical index path: /index + std::string canonical(gitDirEnv); + if (!canonical.empty() && canonical.back() != '\\' && canonical.back() != '/') + canonical += '\\'; + canonical += "index"; + + // Resolve both paths to absolute form so that relative GIT_DIR + // (e.g. ".git") and absolute GIT_INDEX_FILE compare correctly. + char canonicalFull[MAX_PATH]; + char actualFull[MAX_PATH]; + DWORD canonLen = GetFullPathNameA(canonical.c_str(), MAX_PATH, canonicalFull, NULL); + DWORD actualLen = GetFullPathNameA(indexFileEnv, MAX_PATH, actualFull, NULL); + + free(indexFileEnv); + free(gitDirEnv); + + if (canonLen == 0 || canonLen >= MAX_PATH || + actualLen == 0 || actualLen >= MAX_PATH) + { + // Path resolution failed — err on the side of correctness. + return false; + } + + return _stricmp(actualFull, canonicalFull) != 0; +} + int main(int argc, char *argv[]) { if (argc != 3) @@ -15,6 +75,16 @@ int main(int argc, char *argv[]) die(ReturnCode::InvalidArgCount, "Invalid arguments"); } + // Skip notification for non-canonical (temp) index files. + // Git fires post-index-change for every index write, including temp + // indexes created via GIT_INDEX_FILE redirect (e.g. read-tree + // --index-output, git add with a temp index). GVFS only needs to + // know about changes to the real $GIT_DIR/index. + if (IsNonCanonicalIndex()) + { + return 0; + } + if (strcmp(argv[1], "1") && strcmp(argv[1], "0")) { die(PostIndexChangedErrorReturnCode::ErrorPostIndexChangedProtocol, "Invalid value passed for first argument"); diff --git a/GVFS/GVFS.Service.UI/Data/ActionItem.cs b/GVFS/GVFS.Service.UI/Data/ActionItem.cs deleted file mode 100644 index 1034ea0aa..000000000 --- a/GVFS/GVFS.Service.UI/Data/ActionItem.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Xml.Serialization; - -namespace GVFS.Service.UI.Data -{ - [XmlRoot("action")] - public class ActionItem - { - [XmlAttribute("content")] - public string Content { get; set; } - - [XmlAttribute("arguments")] - public string Arguments { get; set; } - - [XmlAttribute("activationtype")] - public string ActivationType { get; set; } - } -} \ No newline at end of file diff --git a/GVFS/GVFS.Service.UI/Data/ActionsData.cs b/GVFS/GVFS.Service.UI/Data/ActionsData.cs deleted file mode 100644 index 56b92af81..000000000 --- a/GVFS/GVFS.Service.UI/Data/ActionsData.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Xml.Serialization; - -namespace GVFS.Service.UI.Data -{ - public class ActionsData - { - [XmlAnyElement("actions")] - public XmlList Actions { get; set; } - } -} diff --git a/GVFS/GVFS.Service.UI/Data/BindingData.cs b/GVFS/GVFS.Service.UI/Data/BindingData.cs deleted file mode 100644 index b364abed5..000000000 --- a/GVFS/GVFS.Service.UI/Data/BindingData.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Xml.Serialization; - -namespace GVFS.Service.UI.Data -{ - public class BindingData - { - [XmlAttribute("template")] - public string Template { get; set; } - - [XmlAnyElement] - public XmlList Items { get; set; } - } -} diff --git a/GVFS/GVFS.Service.UI/Data/BindingItem.cs b/GVFS/GVFS.Service.UI/Data/BindingItem.cs deleted file mode 100644 index e116d1a16..000000000 --- a/GVFS/GVFS.Service.UI/Data/BindingItem.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System.Xml.Serialization; - -namespace GVFS.Service.UI.Data -{ - public abstract class BindingItem - { - [XmlRoot("text")] - public class TextData : BindingItem - { - public TextData() - { - // Required for serialization - } - - public TextData(string value) - { - this.Value = value; - } - - [XmlText] - public string Value { get; set; } - } - - [XmlRoot("image")] - public class ImageData : BindingItem - { - [XmlAttribute("placement")] - public string Placement { get; set; } - - [XmlAttribute("src")] - public string Source { get; set; } - - [XmlAttribute("hint-crop")] - public string HintCrop { get; set; } - } - } -} diff --git a/GVFS/GVFS.Service.UI/Data/ToastData.cs b/GVFS/GVFS.Service.UI/Data/ToastData.cs deleted file mode 100644 index 6750e4e78..000000000 --- a/GVFS/GVFS.Service.UI/Data/ToastData.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Xml.Serialization; - -namespace GVFS.Service.UI.Data -{ - [XmlRoot("toast")] - public class ToastData - { - [XmlAttribute("launch")] - public string Launch { get; set; } - - [XmlElement("visual")] - public VisualData Visual { get; set; } - - [XmlElement("actions")] - public ActionsData Actions { get; set; } - - [XmlElement("scenario")] - public string Scenario { get; set; } - } -} diff --git a/GVFS/GVFS.Service.UI/Data/VisualData.cs b/GVFS/GVFS.Service.UI/Data/VisualData.cs deleted file mode 100644 index 10fb75d49..000000000 --- a/GVFS/GVFS.Service.UI/Data/VisualData.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Xml.Serialization; - -namespace GVFS.Service.UI.Data -{ - public class VisualData - { - [XmlElement("binding")] - public BindingData Binding { get; set; } - } -} diff --git a/GVFS/GVFS.Service.UI/GVFS.Service.UI.csproj b/GVFS/GVFS.Service.UI/GVFS.Service.UI.csproj deleted file mode 100644 index 48e5c1605..000000000 --- a/GVFS/GVFS.Service.UI/GVFS.Service.UI.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - Exe - net471 - - - - - - - - - - - - - PreserveNewest - - - - diff --git a/GVFS/GVFS.Service.UI/GVFSServiceUI.cs b/GVFS/GVFS.Service.UI/GVFSServiceUI.cs deleted file mode 100644 index c81e80b7d..000000000 --- a/GVFS/GVFS.Service.UI/GVFSServiceUI.cs +++ /dev/null @@ -1,56 +0,0 @@ -using GVFS.Common; -using GVFS.Common.NamedPipes; -using GVFS.Common.Tracing; -using System; -using System.Threading; - -namespace GVFS.Service.UI -{ - public class GVFSServiceUI - { - private readonly ITracer tracer; - private readonly GVFSToastRequestHandler toastRequestHandler; - - public GVFSServiceUI(ITracer tracer, GVFSToastRequestHandler toastRequestHandler) - { - this.tracer = tracer; - this.toastRequestHandler = toastRequestHandler; - } - - public void Start(string[] args) - { - using (ITracer activity = this.tracer.StartActivity("Start", EventLevel.Informational)) - using (NamedPipeServer server = NamedPipeServer.StartNewServer(GVFSConstants.Service.UIName, this.tracer, this.HandleRequest)) - { - ManualResetEvent mre = new ManualResetEvent(false); - mre.WaitOne(); - } - } - - private void HandleRequest(ITracer tracer, string request, NamedPipeServer.Connection connection) - { - try - { - NamedPipeMessages.Message message = NamedPipeMessages.Message.FromString(request); - switch (message.Header) - { - case NamedPipeMessages.Notification.Request.Header: - NamedPipeMessages.Notification.Request toastRequest = NamedPipeMessages.Notification.Request.FromMessage(message); - if (toastRequest != null) - { - using (ITracer activity = this.tracer.StartActivity("SendToast", EventLevel.Informational)) - { - this.toastRequestHandler.HandleToastRequest(activity, toastRequest); - } - } - - break; - } - } - catch (Exception e) - { - this.tracer.RelatedError("Unhandled exception: {0}", e.ToString()); - } - } - } -} diff --git a/GVFS/GVFS.Service.UI/GVFSToastRequestHandler.cs b/GVFS/GVFS.Service.UI/GVFSToastRequestHandler.cs deleted file mode 100644 index f6c5872b8..000000000 --- a/GVFS/GVFS.Service.UI/GVFSToastRequestHandler.cs +++ /dev/null @@ -1,193 +0,0 @@ -using GVFS.Common.NamedPipes; -using GVFS.Common.Tracing; -using System; -using System.Diagnostics; -using System.IO; - -namespace GVFS.Service.UI -{ - public class GVFSToastRequestHandler - { - private const string VFSForGitAutomountStartTitle= "VFS For Git Automount"; - private const string VFSForGitAutomountStartMessageFormat = "Attempting to mount {0} VFS For Git {1}"; - private const string VFSForGitMultipleRepos = "repos"; - private const string VFSForGitSingleRepo = "repo"; - - private const string VFSForGitAutomountSuccessTitle = "VFS For Git Automount"; - private const string VFSForGitAutomountSuccessMessageFormat = "The following VFS For Git repo is now mounted: {0}{1}"; - - private const string VFSForGitAutomountErrorTitle = "VFS For Git Automount"; - private const string VFSForGitAutomountErrorMessageFormat = "The following VFS For Git repo failed to mount: {0}{1}"; - private const string VFSForGitAutomountButtonTitle = "Retry"; - - private const string VFSForGitUpgradeTitleFormat = "New version {0} is available"; - private const string VFSForGitUpgradeMessage = "Upgrade will unmount and remount VFS For Git repos, ensure you are at a stopping point. When ready, click Upgrade button to run upgrade."; - private const string VFSForGitUpgradeButtonTitle = "Upgrade"; - - private const string VFSForGitRemountActionPrefix = "gvfs mount"; - private const string VFSForGitUpgradeActionPrefix = "gvfs upgrade --confirm"; - - private readonly ITracer tracer; - private readonly IToastNotifier toastNotifier; - - public GVFSToastRequestHandler(IToastNotifier toastNotifier, ITracer tracer) - { - this.toastNotifier = toastNotifier; - this.toastNotifier.UserResponseCallback = this.UserResponseCallback; - this.tracer = tracer; - } - - public void HandleToastRequest(ITracer tracer, NamedPipeMessages.Notification.Request request) - { - string title = null; - string message = null; - string buttonTitle = null; - string args = null; - string path = null; - - switch (request.Id) - { - case NamedPipeMessages.Notification.Request.Identifier.AutomountStart: - string reposSuffix = request.EnlistmentCount <= 1 ? VFSForGitSingleRepo : VFSForGitMultipleRepos; - title = VFSForGitAutomountStartTitle; - message = string.Format(VFSForGitAutomountStartMessageFormat, request.EnlistmentCount, reposSuffix); - break; - - case NamedPipeMessages.Notification.Request.Identifier.MountSuccess: - if (this.TryValidatePath(request.Enlistment, out path, this.tracer)) - { - title = VFSForGitAutomountSuccessTitle; - message = string.Format(VFSForGitAutomountSuccessMessageFormat, Environment.NewLine, path); - } - - break; - - case NamedPipeMessages.Notification.Request.Identifier.MountFailure: - if (this.TryValidatePath(request.Enlistment, out path, this.tracer)) - { - title = VFSForGitAutomountErrorTitle; - message = string.Format(VFSForGitAutomountErrorMessageFormat, Environment.NewLine, path); - buttonTitle = VFSForGitAutomountButtonTitle; - args = $"{VFSForGitRemountActionPrefix} {path}"; - } - - break; - - case NamedPipeMessages.Notification.Request.Identifier.UpgradeAvailable: - title = string.Format(VFSForGitUpgradeTitleFormat, request.NewVersion); - message = string.Format(VFSForGitUpgradeMessage); - buttonTitle = VFSForGitUpgradeButtonTitle; - args = $"{VFSForGitUpgradeActionPrefix}"; - break; - } - - if (title != null && message != null) - { - this.toastNotifier.Notify(title, message, buttonTitle, args); - } - } - - public void UserResponseCallback(string args) - { - if (string.IsNullOrEmpty(args)) - { - this.tracer.RelatedError($"{nameof(this.UserResponseCallback)}: Received null arguments in Toaster callback."); - return; - } - - using (ITracer activity = this.tracer.StartActivity("GVFSToastCallback", EventLevel.Informational)) - { - string gvfsCmd = null; - bool elevate = false; - - if (args.StartsWith(VFSForGitUpgradeActionPrefix)) - { - this.tracer.RelatedInfo($"gvfs upgrade action."); - gvfsCmd = "gvfs upgrade --confirm"; - elevate = true; - } - else if (args.StartsWith(VFSForGitRemountActionPrefix)) - { - string path = args.Substring(VFSForGitRemountActionPrefix.Length, args.Length - VFSForGitRemountActionPrefix.Length); - if (this.TryValidatePath(path, out string enlistment, activity)) - { - this.tracer.RelatedInfo($"gvfs mount action {enlistment}."); - gvfsCmd = $"gvfs mount \"{enlistment}\""; - } - else - { - EventMetadata metadata = new EventMetadata(); - metadata.Add(nameof(args), args); - metadata.Add(nameof(path), path); - this.tracer.RelatedError(metadata, $"{nameof(this.UserResponseCallback)}- Invalid enlistment path specified in Toaster callback."); - } - } - else - { - this.tracer.RelatedError($"{nameof(this.UserResponseCallback)}- Unknown action({args}) specified in Toaster callback."); - } - - if (!string.IsNullOrEmpty(gvfsCmd)) - { - this.launchGVFSInCommandPrompt(gvfsCmd, elevate, activity); - } - } - } - - private bool TryValidatePath(string path, out string validatedPath, ITracer tracer) - { - try - { - validatedPath = Path.GetFullPath(path); - return true; - } - catch (Exception ex) - { - EventMetadata metadata = new EventMetadata(); - metadata.Add("Exception", ex.ToString()); - metadata.Add("Path", path); - - tracer.RelatedError(metadata, $"{nameof(this.TryValidatePath)}: {path}. {ex.ToString()}"); - } - - validatedPath = null; - return false; - } - - private void launchGVFSInCommandPrompt(string fullGvfsCmd, bool elevate, ITracer tracer) - { - const string cmdPath = "CMD.exe"; - ProcessStartInfo processInfo = new ProcessStartInfo(cmdPath); - processInfo.UseShellExecute = true; - processInfo.RedirectStandardInput = false; - processInfo.RedirectStandardOutput = false; - processInfo.RedirectStandardError = false; - processInfo.WindowStyle = ProcessWindowStyle.Normal; - processInfo.CreateNoWindow = false; - - // /K option is so the user gets the time to read the output of the command and - // manually close the cmd window after that. - processInfo.Arguments = "/K " + fullGvfsCmd; - if (elevate) - { - processInfo.Verb = "runas"; - } - - tracer.RelatedInfo($"{nameof(this.UserResponseCallback)}- Running {cmdPath} /K {fullGvfsCmd}"); - - try - { - Process.Start(processInfo); - } - catch (Exception ex) - { - EventMetadata metadata = new EventMetadata(); - metadata.Add("Exception", ex.ToString()); - metadata.Add(nameof(fullGvfsCmd), fullGvfsCmd); - metadata.Add(nameof(elevate), elevate); - - tracer.RelatedError(metadata, $"{nameof(this.launchGVFSInCommandPrompt)}: Error launching {fullGvfsCmd}. {ex.ToString()}"); - } - } - } -} diff --git a/GVFS/GVFS.Service.UI/IToastNotifier.cs b/GVFS/GVFS.Service.UI/IToastNotifier.cs deleted file mode 100644 index 60cd2f15b..000000000 --- a/GVFS/GVFS.Service.UI/IToastNotifier.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System; - -namespace GVFS.Service.UI -{ - public interface IToastNotifier - { - Action UserResponseCallback { get; set; } - void Notify(string title, string message, string actionButtonTitle, string callbackArgs); - } -} diff --git a/GVFS/GVFS.Service.UI/Program.cs b/GVFS/GVFS.Service.UI/Program.cs deleted file mode 100644 index 3c03bbc66..000000000 --- a/GVFS/GVFS.Service.UI/Program.cs +++ /dev/null @@ -1,46 +0,0 @@ -using GVFS.Common; -using GVFS.Common.Tracing; -using GVFS.PlatformLoader; -using System; - -namespace GVFS.Service.UI -{ - public static class Program - { - public static void Main(string[] args) - { - GVFSPlatformLoader.Initialize(); - - using (JsonTracer tracer = new JsonTracer("Microsoft.Git.GVFS.Service.UI", "Service.UI")) - { - string error; - string serviceUILogDirectory = GVFSPlatform.Instance.GetLogsDirectoryForGVFSComponent(GVFSConstants.Service.UIName); - if (!GVFSPlatform.Instance.FileSystem.TryCreateDirectoryWithAdminAndUserModifyPermissions(serviceUILogDirectory, out error)) - { - EventMetadata metadata = new EventMetadata(); - metadata.Add(nameof(serviceUILogDirectory), serviceUILogDirectory); - metadata.Add(nameof(error), error); - tracer.RelatedWarning( - metadata, - "Failed to create service UI logs directory", - Keywords.Telemetry); - } - else - { - string logFilePath = GVFSEnlistment.GetNewGVFSLogFileName( - serviceUILogDirectory, - GVFSConstants.LogFileTypes.ServiceUI, - logId: Environment.UserName); - - tracer.AddLogFileEventListener(logFilePath, EventLevel.Informational, Keywords.Any); - } - - WinToastNotifier winToastNotifier = new WinToastNotifier(tracer); - GVFSToastRequestHandler toastRequestHandler = new GVFSToastRequestHandler(winToastNotifier, tracer); - GVFSServiceUI process = new GVFSServiceUI(tracer, toastRequestHandler); - - process.Start(args); - } - } - } -} \ No newline at end of file diff --git a/GVFS/GVFS.Service.UI/WinToastNotifier.cs b/GVFS/GVFS.Service.UI/WinToastNotifier.cs deleted file mode 100644 index 8cf364dfa..000000000 --- a/GVFS/GVFS.Service.UI/WinToastNotifier.cs +++ /dev/null @@ -1,103 +0,0 @@ -using GVFS.Common; -using GVFS.Common.Tracing; -using GVFS.Service.UI.Data; -using System; -using System.IO; -using System.Xml; -using System.Xml.Serialization; -using Windows.UI.Notifications; -using XmlDocument = Windows.Data.Xml.Dom.XmlDocument; - -namespace GVFS.Service.UI -{ - public class WinToastNotifier : IToastNotifier - { - private const string ServiceAppId = "GVFS"; - private const string GVFSIconName = "GitVirtualFileSystem.ico"; - private ITracer tracer; - - public WinToastNotifier(ITracer tracer) - { - this.tracer = tracer; - } - - public Action UserResponseCallback { get; set; } - - public void Notify(string title, string message, string actionButtonTitle, string callbackArgs) - { - // Reference: https://docs.microsoft.com/en-us/windows/uwp/design/shell/tiles-and-notifications/adaptive-interactive-toasts - ToastData toastData = new ToastData(); - - toastData.Visual = new VisualData(); - - BindingData binding = new BindingData(); - toastData.Visual.Binding = binding; - - // ToastGeneric- Our toast contains VFSForGit icon and text - binding.Template = "ToastGeneric"; - binding.Items = new XmlList(); - binding.Items.Add(new BindingItem.TextData(title)); - binding.Items.Add(new BindingItem.TextData(message)); - - string logo = "file:///" + Path.Combine(ProcessHelper.GetCurrentProcessLocation(), GVFSIconName); - binding.Items.Add(new BindingItem.ImageData() - { - Source = logo, - Placement = "appLogoOverride", - HintCrop = "circle" - }); - - if (!string.IsNullOrEmpty(actionButtonTitle)) - { - ActionsData actionsData = new ActionsData(); - actionsData.Actions = new XmlList(); - actionsData.Actions.Add(new ActionItem() - { - Content = actionButtonTitle, - Arguments = string.IsNullOrEmpty(callbackArgs) ? string.Empty : callbackArgs, - ActivationType = "background" - }); - - toastData.Actions = actionsData; - } - - XmlDocument toastXml = new XmlDocument(); - using (StringWriter stringWriter = new StringWriter()) - using (XmlWriter xmlWriter = XmlWriter.Create(stringWriter, new XmlWriterSettings { OmitXmlDeclaration = true })) - { - XmlSerializer serializer = new XmlSerializer(toastData.GetType()); - XmlSerializerNamespaces namespaces = new XmlSerializerNamespaces(); - namespaces.Add(string.Empty, string.Empty); - - serializer.Serialize(xmlWriter, toastData, namespaces); - - toastXml.LoadXml(stringWriter.ToString()); - } - - ToastNotification toastNotification = new ToastNotification(toastXml); - toastNotification.Activated += this.ToastActivated; - toastNotification.Dismissed += this.ToastDismissed; - toastNotification.Failed += this.ToastFailed; - - ToastNotifier toastNotifier = ToastNotificationManager.CreateToastNotifier(ServiceAppId); - toastNotifier.Show(toastNotification); - } - - private void ToastActivated(ToastNotification sender, object e) - { - ToastActivatedEventArgs args = (ToastActivatedEventArgs)e; - - this.UserResponseCallback?.Invoke(args.Arguments); - } - - private void ToastDismissed(ToastNotification sender, ToastDismissedEventArgs e) - { - this.tracer.RelatedInfo($"{nameof(this.ToastDismissed)}: {e.Reason}"); - } - - private void ToastFailed(ToastNotification sender, ToastFailedEventArgs e) - { - this.tracer.RelatedInfo($"{nameof(this.ToastFailed)}: {e.ErrorCode.ToString()}"); - } - } -} diff --git a/GVFS/GVFS.Service.UI/XmlList.cs b/GVFS/GVFS.Service.UI/XmlList.cs deleted file mode 100644 index 06a2dad50..000000000 --- a/GVFS/GVFS.Service.UI/XmlList.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Xml; -using System.Xml.Schema; -using System.Xml.Serialization; - -namespace GVFS.Service.UI -{ - public class XmlList : List, IXmlSerializable where T : class - { - public XmlSchema GetSchema() - { - throw new NotImplementedException(); - } - - public void ReadXml(XmlReader reader) - { - throw new NotImplementedException(); - } - - public void WriteXml(XmlWriter writer) - { - XmlSerializerNamespaces ns = new XmlSerializerNamespaces(); - ns.Add(string.Empty, string.Empty); - foreach (T item in this) - { - XmlSerializer xml = new XmlSerializer(item.GetType()); - xml.Serialize(writer, item, ns); - } - } - } -} diff --git a/GVFS/GVFS.Service/Configuration.cs b/GVFS/GVFS.Service/Configuration.cs index f5c8b65be..0e4ebe18e 100644 --- a/GVFS/GVFS.Service/Configuration.cs +++ b/GVFS/GVFS.Service/Configuration.cs @@ -11,7 +11,6 @@ public class Configuration private Configuration() { this.GVFSLocation = Path.Combine(AssemblyPath, GVFSPlatform.Instance.Constants.GVFSExecutableName); - this.GVFSServiceUILocation = Path.Combine(AssemblyPath, GVFSConstants.Service.UIName + GVFSPlatform.Instance.Constants.ExecutableExtension); } public static Configuration Instance @@ -36,6 +35,5 @@ public static string AssemblyPath } public string GVFSLocation { get; private set; } - public string GVFSServiceUILocation { get; private set; } } } diff --git a/GVFS/GVFS.Service/GVFS.Service.csproj b/GVFS/GVFS.Service/GVFS.Service.csproj index c24eb6505..6bb3ec81b 100644 --- a/GVFS/GVFS.Service/GVFS.Service.csproj +++ b/GVFS/GVFS.Service/GVFS.Service.csproj @@ -1,23 +1,19 @@ - + Exe - net471 true - - - - - - - + + + + diff --git a/GVFS/GVFS.Service/GVFSService.Windows.cs b/GVFS/GVFS.Service/GVFSService.Windows.cs index 5b3048b74..bedfa9a7e 100644 --- a/GVFS/GVFS.Service/GVFSService.Windows.cs +++ b/GVFS/GVFS.Service/GVFSService.Windows.cs @@ -5,10 +5,8 @@ using GVFS.Platform.Windows; using GVFS.Service.Handlers; using System; -using System.Diagnostics; using System.IO; using System.Linq; -using System.Runtime.Serialization; using System.Security.AccessControl; using System.ServiceProcess; using System.Threading; @@ -45,6 +43,12 @@ public void Run() metadata.Add("Version", ProcessHelper.GetCurrentProcessVersion()); this.tracer.RelatedEvent(EventLevel.Informational, $"{nameof(GVFSService)}_{nameof(this.Run)}", metadata); + // Check for a staged upgrade before doing anything else. + // If no GVFS.Mount processes are running (typical at boot or after + // unmount-all), copy staged files in-place and proceed normally. + // If mounts ARE running, the upgrade is deferred to next restart. + PendingUpgradeHandler.TryApplyPendingUpgrade(this.tracer); + this.repoRegistry = new RepoRegistry( this.tracer, new PhysicalFileSystem(), @@ -130,8 +134,6 @@ protected override void OnSessionChange(SessionChangeDescription changeDescripti { this.tracer.RelatedInfo("SessionLogon detected, sessionId: {0}", changeDescription.SessionId); - this.LaunchServiceUIIfNotRunning(changeDescription.SessionId); - using (ITracer activity = this.tracer.StartActivity("LogonAutomount", EventLevel.Informational)) { this.repoRegistry.AutoMountRepos( @@ -353,14 +355,11 @@ private void CreateAndConfigureProgramDataDirectories() DirectorySecurity serviceDataRootSecurity = this.GetServiceDirectorySecurity(serviceDataRootPath); // Create GVFS.Service related directories (if they don't already exist) - Directory.CreateDirectory(serviceDataRootPath, serviceDataRootSecurity); - Directory.CreateDirectory(this.serviceDataLocation, serviceDataRootSecurity); + serviceDataRootSecurity.CreateDirectory(serviceDataRootPath); + serviceDataRootSecurity.CreateDirectory(this.serviceDataLocation); // Ensure the ACLs are set correctly on any files or directories that were already created (e.g. after upgrading VFS4G) - Directory.SetAccessControl(serviceDataRootPath, serviceDataRootSecurity); - - // Special rules for the Service.UI logs, as non-elevated users need to be be able to write - this.CreateAndConfigureLogDirectory(GVFSPlatform.Instance.GetLogsDirectoryForGVFSComponent(GVFSConstants.Service.UIName)); + new DirectoryInfo(serviceDataRootPath).SetAccessControl(serviceDataRootSecurity); } private void CreateAndConfigureLogDirectory(string path) @@ -385,7 +384,7 @@ private DirectorySecurity GetServiceDirectorySecurity(string serviceDataRootPath if (Directory.Exists(serviceDataRootPath)) { this.tracer.RelatedInfo($"{nameof(this.GetServiceDirectorySecurity)}: {serviceDataRootPath} exists, modifying ACLs."); - serviceDataRootSecurity = Directory.GetAccessControl(serviceDataRootPath); + serviceDataRootSecurity = new DirectoryInfo(serviceDataRootPath).GetAccessControl(); } else { @@ -404,50 +403,5 @@ private DirectorySecurity GetServiceDirectorySecurity(string serviceDataRootPath return serviceDataRootSecurity; } - private void LaunchServiceUIIfNotRunning(int sessionId) - { - NamedPipeClient client; - using (client = new NamedPipeClient(GVFSConstants.Service.UIName)) - { - if (!client.Connect()) - { - this.tracer.RelatedError($"Could not connect with {GVFSConstants.Service.UIName}. Attempting to relaunch."); - - this.TerminateExistingProcess(GVFSConstants.Service.UIName, sessionId); - - CurrentUser currentUser = new CurrentUser(this.tracer, sessionId); - if (!currentUser.RunAs( - Configuration.Instance.GVFSServiceUILocation, - string.Empty)) - { - this.tracer.RelatedError("Could not start " + GVFSConstants.Service.UIName); - } - else - { - this.tracer.RelatedInfo($"Successfully launched {GVFSConstants.Service.UIName}. "); - } - } - } - } - - private void TerminateExistingProcess(string processName, int sessionId) - { - try - { - foreach (Process process in Process.GetProcessesByName(processName)) - { - if (process.SessionId == sessionId) - { - this.tracer.RelatedInfo($"{nameof(this.TerminateExistingProcess)}- Stopping {processName}, in session {sessionId}."); - - process.Kill(); - } - } - } - catch (Exception ex) - { - this.tracer.RelatedError("Could not find and kill existing instances of {0}: {1}", processName, ex.Message); - } - } } } diff --git a/GVFS/GVFS.Service/Handlers/NotificationHandler.cs b/GVFS/GVFS.Service/Handlers/NotificationHandler.cs index a7777b8fc..a0ec6876c 100644 --- a/GVFS/GVFS.Service/Handlers/NotificationHandler.cs +++ b/GVFS/GVFS.Service/Handlers/NotificationHandler.cs @@ -1,47 +1,16 @@ -using GVFS.Common; -using GVFS.Common.NamedPipes; +using GVFS.Common.NamedPipes; using GVFS.Common.Tracing; -using GVFS.Platform.Windows; -using System; -using System.Diagnostics; namespace GVFS.Service.Handlers { public class NotificationHandler : INotificationHandler { - private ITracer tracer; - public NotificationHandler(ITracer tracer) { - this.tracer = tracer; } public void SendNotification(NamedPipeMessages.Notification.Request request) { - using (NamedPipeClient client = new NamedPipeClient(GVFSConstants.Service.UIName)) - { - if (client.Connect()) - { - try - { - if (!client.TrySendRequest(request.ToMessage())) - { - this.tracer.RelatedInfo("Failed to send notification request to " + GVFSConstants.Service.UIName); - } - } - catch (Exception ex) - { - EventMetadata metadata = new EventMetadata(); - metadata.Add("Exception", ex.ToString()); - metadata.Add("Identifier", request.Id); - this.tracer.RelatedError(metadata, $"{nameof(this.SendNotification)}- Could not send notification request({request.Id}. {ex.ToString()}"); - } - } - else - { - this.tracer.RelatedError($"{nameof(this.SendNotification)}- Could not connect with GVFS.Service.UI, failed to send notification request({request.Id}."); - } - } } } } diff --git a/GVFS/GVFS.Service/Handlers/RequestHandler.cs b/GVFS/GVFS.Service/Handlers/RequestHandler.cs index 724bfa3b5..4d665c416 100644 --- a/GVFS/GVFS.Service/Handlers/RequestHandler.cs +++ b/GVFS/GVFS.Service/Handlers/RequestHandler.cs @@ -1,6 +1,7 @@ using GVFS.Common.NamedPipes; using GVFS.Common.Tracing; using System.Runtime.Serialization; +using System.Threading; namespace GVFS.Service.Handlers { @@ -14,6 +15,8 @@ namespace GVFS.Service.Handlers /// public class RequestHandler { + private const int PendingUpgradeDelayMs = 5000; + protected const string EnableProjFSRequestDescription = "attach volume"; protected string requestDescription; @@ -25,6 +28,8 @@ public class RequestHandler private string etwArea; private ITracer tracer; private IRepoRegistry repoRegistry; + private Timer pendingUpgradeTimer; + private readonly object pendingUpgradeTimerLock = new object(); public RequestHandler(ITracer tracer, string etwArea, IRepoRegistry repoRegistry) { @@ -80,6 +85,14 @@ protected virtual void HandleMessage( UnregisterRepoHandler unmountHandler = new UnregisterRepoHandler(tracer, this.repoRegistry, connection, unmountRequest); unmountHandler.Run(); + // After unmount, check for pending staged upgrade on a + // background thread. The deferred check gives the calling + // GVFS.Mount process time to exit so its executable is no + // longer locked when the upgrade runs. + // Use the long-lived service tracer, not the scoped activity + // tracer which will be disposed when this handler returns. + this.TryDeferredPendingUpgradeCheck(this.tracer); + break; case NamedPipeMessages.GetActiveRepoListRequest.Header: @@ -121,5 +134,38 @@ private void TrySendResponse( tracer.RelatedError($"{nameof(this.TrySendResponse)}: Could not send response to client. Reply Info: {message}"); } } + + private void TryDeferredPendingUpgradeCheck(ITracer tracer) + { + string installDir = Service.Configuration.AssemblyPath; + string pendingUpgradeDir = System.IO.Path.Combine(installDir, PendingUpgradeHandler.PendingUpgradeDirectoryName); + if (!System.IO.Directory.Exists(pendingUpgradeDir)) + { + return; + } + + // Debounce: reset the timer on each unmount so the check fires + // once after the last unmount settles. If multiple repos unmount + // in quick succession, only one upgrade attempt runs. + lock (this.pendingUpgradeTimerLock) + { + if (this.pendingUpgradeTimer == null) + { + this.pendingUpgradeTimer = new Timer( + _ => + { + tracer.RelatedInfo("TryDeferredPendingUpgradeCheck: Checking pending upgrade after unmount"); + PendingUpgradeHandler.TryApplyPendingUpgrade(tracer); + }, + null, + PendingUpgradeDelayMs, + Timeout.Infinite); + } + else + { + this.pendingUpgradeTimer.Change(PendingUpgradeDelayMs, Timeout.Infinite); + } + } + } } } diff --git a/GVFS/GVFS.Service/PendingUpgradeHandler.cs b/GVFS/GVFS.Service/PendingUpgradeHandler.cs new file mode 100644 index 000000000..6bfabece4 --- /dev/null +++ b/GVFS/GVFS.Service/PendingUpgradeHandler.cs @@ -0,0 +1,443 @@ +using GVFS.Common; +using GVFS.Common.Tracing; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Threading; + +namespace GVFS.Service +{ + /// + /// Detects and applies staged upgrades from the PendingUpgrade directory. + /// + /// When the installer runs with mounts active, it stages new files to + /// {installDir}\PendingUpgrade\ instead of replacing files in-place. + /// This class applies the upgrade when no GVFS.Mount processes are + /// running — either on service start (before automount) or after a + /// repo unmount (via deferred check from RequestHandler). + /// + /// 1. Move old files from install dir → PreviousVersion\ + /// 2. Move new files from PendingUpgrade\ → install dir + /// 3. Delete PreviousVersion\ and PendingUpgrade\ + /// + /// File.Move on the same volume is an atomic rename at the filesystem + /// level, so a crash mid-upgrade leaves files intact (either at the old + /// or new location). On retry, the handler resumes from where it left off. + /// + /// With native AOT, each exe is self-contained — the only locked file + /// is GVFS.Service.exe itself, which the installer already replaced. + /// + public static class PendingUpgradeHandler + { + public const string PendingUpgradeDirectoryName = "PendingUpgrade"; + private const string PreviousVersionDirectoryName = "PreviousVersion"; + private const string ReadyMarkerFileName = ".ready"; + private const string Phase1CompleteMarkerFileName = ".phase1-complete"; + private const string ServiceExeName = "GVFS.Service.exe"; + private const string MountProcessName = "GVFS.Mount"; + + // Executables that users or the service can launch to start new + // mount/hook processes. During upgrade these are moved out first + // (Phase 1) and moved in last (Phase 2) so that no new GVFS + // processes can start while the upgrade is in progress. + // Ordered most-likely-to-be-called first for Phase 1 removal. + private static readonly StringComparer PathComparer = StringComparer.OrdinalIgnoreCase; + private static readonly string[] PriorityExes = new[] + { + "GVFS.Hooks.exe", + "GVFS.exe", + "GVFS.Mount.exe", + }; + + /// + /// Checks for and applies a pending staged upgrade. + /// + public static void TryApplyPendingUpgrade(ITracer tracer) + { + string installDir = Configuration.AssemblyPath; + string pendingUpgradeDir = Path.Combine(installDir, PendingUpgradeDirectoryName); + string previousVersionDir = Path.Combine(installDir, PreviousVersionDirectoryName); + + if (!Directory.Exists(pendingUpgradeDir)) + { + // No pending upgrade. Clean up PreviousVersion if it exists + // (leftover from a completed upgrade where cleanup was interrupted). + TryDeleteDirectory(tracer, previousVersionDir, "leftover PreviousVersion"); + return; + } + + // Installer writes .ready marker as its last step. If missing, + // the installer was interrupted mid-write — don't apply partial files. + string readyMarker = Path.Combine(pendingUpgradeDir, ReadyMarkerFileName); + if (!File.Exists(readyMarker)) + { + EventMetadata readyMetadata = new EventMetadata(); + readyMetadata.Add("PendingUpgradeDir", pendingUpgradeDir); + tracer.RelatedWarning( + readyMetadata, + $"{nameof(PendingUpgradeHandler)}: PendingUpgrade directory exists but {ReadyMarkerFileName} marker " + + "is missing — installer was likely interrupted. Skipping until next install completes.", + Keywords.Telemetry); + return; + } + + tracer.RelatedInfo($"{nameof(PendingUpgradeHandler)}: Pending upgrade detected at {pendingUpgradeDir}"); + + // Don't apply if GVFS.Mount processes are still running — their + // executables are locked and moves would fail. Upgrade will be + // retried on next service start when no mounts are active. + Process[] mountProcesses = Array.Empty(); + try + { + mountProcesses = Process.GetProcessesByName(MountProcessName); + if (mountProcesses.Length > 0) + { + EventMetadata deferMetadata = new EventMetadata(); + deferMetadata.Add("MountProcessCount", mountProcesses.Length); + tracer.RelatedEvent( + EventLevel.Informational, + $"{nameof(PendingUpgradeHandler)}_Deferred", + deferMetadata, + Keywords.Telemetry); + return; + } + } + finally + { + foreach (Process p in mountProcesses) + { + p.Dispose(); + } + } + + try + { + // Phase 1: Move old files to PreviousVersion (backup for rollback). + // priority exes (GVFS.exe, GVFS.Hooks.exe, GVFS.Mount.exe) are + // moved FIRST so no new GVFS processes can start during the upgrade. + // Use a marker file to track completion — directory existence alone + // is insufficient because a crash mid-phase leaves the directory + // with only some files backed up. + string[] stagedFiles = Directory.GetFiles(pendingUpgradeDir, "*", SearchOption.AllDirectories); + string phase1Marker = Path.Combine(previousVersionDir, Phase1CompleteMarkerFileName); + if (!File.Exists(phase1Marker)) + { + // Clean up any partial Phase 1 from a prior crash — re-run + // from scratch to ensure all files are backed up. + if (Directory.Exists(previousVersionDir)) + { + tracer.RelatedWarning( + $"{nameof(PendingUpgradeHandler)}: Phase 1 incomplete from prior attempt, restarting backup", + Keywords.Telemetry); + TryRestoreFromPreviousVersion(tracer, previousVersionDir, installDir); + TryDeleteDirectory(tracer, previousVersionDir, "incomplete PreviousVersion"); + } + + tracer.RelatedInfo($"{nameof(PendingUpgradeHandler)}: Phase 1 - backing up {stagedFiles.Length} file(s) to PreviousVersion"); + + int backedUp = 0; + foreach (string relativePath in OrderForRemoval(stagedFiles, pendingUpgradeDir)) + { + string installedFile = Path.Combine(installDir, relativePath); + if (File.Exists(installedFile)) + { + string backupFile = Path.Combine(previousVersionDir, relativePath); + string backupDir = Path.GetDirectoryName(backupFile); + if (!Directory.Exists(backupDir)) + { + Directory.CreateDirectory(backupDir); + } + + MoveFileWithRetry(tracer, installedFile, backupFile); + backedUp++; + } + } + + File.WriteAllText(phase1Marker, string.Empty); + tracer.RelatedInfo($"{nameof(PendingUpgradeHandler)}: Phase 1 complete. Backed up {backedUp} file(s)"); + } + else + { + tracer.RelatedInfo($"{nameof(PendingUpgradeHandler)}: Phase 1 already done ({Phase1CompleteMarkerFileName} exists). Resuming phase 2."); + } + + // Phase 2: Move new files from PendingUpgrade to install dir. + // priority exes are moved LAST so all supporting files (DLLs, + // hooks, etc.) are in place before any GVFS process can start. + tracer.RelatedInfo($"{nameof(PendingUpgradeHandler)}: Phase 2 - applying {stagedFiles.Length} staged file(s)"); + + int applied = 0; + foreach (string relativePath in OrderForInstall(stagedFiles, pendingUpgradeDir)) + { + string sourceFile = Path.Combine(pendingUpgradeDir, relativePath); + string destFile = Path.Combine(installDir, relativePath); + string destDir = Path.GetDirectoryName(destFile); + if (!Directory.Exists(destDir)) + { + Directory.CreateDirectory(destDir); + } + + // If dest already exists (phase 2 partially completed on a prior + // run), delete it first so File.Move can succeed. + if (File.Exists(destFile)) + { + File.Delete(destFile); + } + + MoveFileWithRetry(tracer, sourceFile, destFile); + applied++; + } + + tracer.RelatedInfo( + $"{nameof(PendingUpgradeHandler)}: Phase 2 complete. Applied={applied}"); + + // Phase 3: Clean up + // Capture old version before deleting PreviousVersion. + string oldVersion = TryGetOldVersion(previousVersionDir); + + // Delete the skipped GVFS.Service.exe from PendingUpgrade first, + // otherwise Directory.Delete will fail on the non-empty directory. + string skippedServiceExe = Path.Combine(pendingUpgradeDir, ServiceExeName); + if (File.Exists(skippedServiceExe)) + { + File.Delete(skippedServiceExe); + } + + TryDeleteDirectory(tracer, pendingUpgradeDir, "PendingUpgrade"); + TryDeleteDirectory(tracer, previousVersionDir, "PreviousVersion"); + + string newVersion = ProcessHelper.GetCurrentProcessVersion(); + EventMetadata successMetadata = new EventMetadata(); + successMetadata.Add("NewVersion", newVersion); + successMetadata.Add("OldVersion", oldVersion ?? "unknown"); + successMetadata.Add("FilesApplied", applied); + tracer.RelatedEvent( + EventLevel.Informational, + $"{nameof(PendingUpgradeHandler)}_Complete", + successMetadata, + Keywords.Telemetry); + return; + } + catch (Exception ex) + { + EventMetadata errorMetadata = new EventMetadata(); + errorMetadata.Add("Exception", ex.ToString()); + tracer.RelatedError( + errorMetadata, + $"{nameof(PendingUpgradeHandler)}: Upgrade failed: {ex.Message}. " + + "PendingUpgrade retained for retry on next service start. " + + "If PreviousVersion exists, old files are preserved for manual recovery.", + Keywords.Telemetry); + return; + } + } + + private static bool IsSkippedFile(string relativePath) + { + return IsMarkerFile(relativePath) || + string.Equals(relativePath, ServiceExeName, StringComparison.OrdinalIgnoreCase); + } + + private static bool IsPriorityExe(string relativePath) + { + foreach (string exe in PriorityExes) + { + if (PathComparer.Equals(relativePath, exe)) + { + return true; + } + } + + return false; + } + + private static bool IsMarkerFile(string relativePath) + { + return string.Equals(relativePath, ReadyMarkerFileName, StringComparison.OrdinalIgnoreCase) || + string.Equals(relativePath, Phase1CompleteMarkerFileName, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Moves a file, retrying once after killing any process that has the + /// source file locked (e.g. a GVFS process that started mid-upgrade). + /// + private static void MoveFileWithRetry(ITracer tracer, string source, string dest) + { + try + { + File.Move(source, dest); + } + catch (IOException) + { + if (TryKillProcessByPath(tracer, source)) + { + Thread.Sleep(1000); + File.Move(source, dest); + } + else + { + throw; + } + } + } + + /// + /// Returns relative paths ordered for removal: priority exes first + /// (so no new GVFS processes can start), then everything else. + /// Skips marker files and GVFS.Service.exe. + /// + private static List OrderForRemoval(string[] absolutePaths, string baseDir) + { + List rest = new List(); + HashSet present = new HashSet(PathComparer); + + foreach (string fullPath in absolutePaths) + { + string relativePath = fullPath.Substring(baseDir.Length).TrimStart(Path.DirectorySeparatorChar); + if (IsSkippedFile(relativePath)) + { + continue; + } + + if (IsPriorityExe(relativePath)) + { + present.Add(relativePath); + } + else + { + rest.Add(relativePath); + } + } + + List ordered = new List(); + foreach (string exe in PriorityExes) + { + if (present.Contains(exe)) + { + ordered.Add(exe); + } + } + + ordered.AddRange(rest); + return ordered; + } + + /// + /// Returns relative paths ordered for install: reverse of removal order + /// so priority exes are replaced last (all supporting files in place + /// before any GVFS process can start). + /// + private static List OrderForInstall(string[] absolutePaths, string baseDir) + { + List ordered = OrderForRemoval(absolutePaths, baseDir); + ordered.Reverse(); + return ordered; + } + + /// + /// Finds and kills any process whose main module matches the given + /// file path. Returns true if a process was found and killed. + /// + private static bool TryKillProcessByPath(ITracer tracer, string filePath) + { + bool killed = false; + try + { + foreach (Process process in Process.GetProcesses()) + { + try + { + if (PathComparer.Equals(process.MainModule?.FileName, filePath)) + { + tracer.RelatedWarning( + $"{nameof(PendingUpgradeHandler)}: Killing process {process.ProcessName} " + + $"(PID {process.Id}) that is locking {filePath}"); + process.Kill(); + process.WaitForExit(5000); + killed = true; + } + } + catch (Exception) + { + // Access denied or process already exited — skip + } + finally + { + process.Dispose(); + } + } + } + catch (Exception ex) + { + tracer.RelatedWarning($"{nameof(PendingUpgradeHandler)}: Error enumerating processes: {ex.Message}"); + } + + return killed; + } + + private static string TryGetOldVersion(string previousVersionDir) + { + try + { + string oldGvfsExe = Path.Combine(previousVersionDir, "GVFS.exe"); + if (File.Exists(oldGvfsExe)) + { + return FileVersionInfo.GetVersionInfo(oldGvfsExe).ProductVersion; + } + } + catch + { + } + + return null; + } + + private static void TryRestoreFromPreviousVersion(ITracer tracer, string previousVersionDir, string installDir) + { + // Move any backed-up files back to the install directory so we + // can retry Phase 1 cleanly. + try + { + foreach (string backupFile in Directory.GetFiles(previousVersionDir, "*", SearchOption.AllDirectories)) + { + string relativePath = backupFile.Substring(previousVersionDir.Length).TrimStart(Path.DirectorySeparatorChar); + if (IsMarkerFile(relativePath)) + { + continue; + } + + string installedFile = Path.Combine(installDir, relativePath); + if (!File.Exists(installedFile)) + { + File.Move(backupFile, installedFile); + } + } + } + catch (Exception ex) + { + tracer.RelatedWarning( + $"{nameof(PendingUpgradeHandler)}: Failed to restore from PreviousVersion: {ex.Message}", + Keywords.Telemetry); + } + } + + private static void TryDeleteDirectory(ITracer tracer, string path, string description) + { + if (!Directory.Exists(path)) + { + return; + } + + try + { + Directory.Delete(path, recursive: true); + tracer.RelatedInfo($"{nameof(PendingUpgradeHandler)}: Removed {description} directory"); + } + catch (Exception ex) + { + tracer.RelatedWarning($"{nameof(PendingUpgradeHandler)}: Failed to remove {description} directory: {ex.Message}"); + } + } + } +} diff --git a/GVFS/GVFS.Service/RepoRegistration.cs b/GVFS/GVFS.Service/RepoRegistration.cs index b6c076951..964b030c3 100644 --- a/GVFS/GVFS.Service/RepoRegistration.cs +++ b/GVFS/GVFS.Service/RepoRegistration.cs @@ -1,4 +1,4 @@ -using Newtonsoft.Json; +using System.Text.Json; namespace GVFS.Service { @@ -19,14 +19,13 @@ public RepoRegistration(string enlistmentRoot, string ownerSID) public string OwnerSID { get; set; } public bool IsActive { get; set; } + // Uses ServiceJsonContext (assembly-local source generator) instead of + // GVFSJsonOptions because RepoRegistration cannot be registered in + // GVFSJsonContext (GVFS.Common) — wrong assembly direction. The + // reflection fallback in GVFSJsonOptions fails under native AOT trimming. public static RepoRegistration FromJson(string json) { - return JsonConvert.DeserializeObject( - json, - new JsonSerializerSettings - { - MissingMemberHandling = MissingMemberHandling.Ignore - }); + return JsonSerializer.Deserialize(json, ServiceJsonContext.Default.RepoRegistration); } public override string ToString() @@ -41,7 +40,7 @@ public override string ToString() public string ToJson() { - return JsonConvert.SerializeObject(this); + return JsonSerializer.Serialize(this, ServiceJsonContext.Default.RepoRegistration); } } } \ No newline at end of file diff --git a/GVFS/GVFS.Service/ServiceJsonContext.cs b/GVFS/GVFS.Service/ServiceJsonContext.cs new file mode 100644 index 000000000..3492b2289 --- /dev/null +++ b/GVFS/GVFS.Service/ServiceJsonContext.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace GVFS.Service +{ + /// + /// Source-generated JSON context for GVFS.Service types that cannot be registered + /// in GVFSJsonContext (GVFS.Common) due to assembly dependency direction. + /// Required for native AOT where the DefaultJsonTypeInfoResolver reflection + /// fallback is not available. + /// + [JsonSerializable(typeof(RepoRegistration))] + internal partial class ServiceJsonContext : JsonSerializerContext + { + } +} diff --git a/GVFS/GVFS.Tests/GVFS.Tests.csproj b/GVFS/GVFS.Tests/GVFS.Tests.csproj index c8c173ebf..19d7e97e5 100644 --- a/GVFS/GVFS.Tests/GVFS.Tests.csproj +++ b/GVFS/GVFS.Tests/GVFS.Tests.csproj @@ -1,12 +1,12 @@ - net471 - - + + + diff --git a/GVFS/GVFS.UnitTests/CommandLine/HooksInstallerTests.cs b/GVFS/GVFS.UnitTests/CommandLine/HooksInstallerTests.cs index a9a5fcbf9..9692a46f1 100644 --- a/GVFS/GVFS.UnitTests/CommandLine/HooksInstallerTests.cs +++ b/GVFS/GVFS.UnitTests/CommandLine/HooksInstallerTests.cs @@ -16,7 +16,7 @@ public class HooksInstallerTests { private const string Filename = "hooksfile"; private readonly string expectedAbsoluteGvfsHookPath = - $"\"{Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), GVFSPlatform.Instance.Constants.GVFSHooksExecutableName)}\""; + $"\"{Path.Combine(Path.GetDirectoryName(Environment.ProcessPath), GVFSPlatform.Instance.Constants.GVFSHooksExecutableName)}\""; [TestCase] [Category(CategoryConstants.ExceptionExpected)] diff --git a/GVFS/GVFS.UnitTests/CommandLine/VersionOutputTests.cs b/GVFS/GVFS.UnitTests/CommandLine/VersionOutputTests.cs new file mode 100644 index 000000000..c94ece57c --- /dev/null +++ b/GVFS/GVFS.UnitTests/CommandLine/VersionOutputTests.cs @@ -0,0 +1,73 @@ +using NUnit.Framework; +using System; +using System.CommandLine; +using System.IO; +using System.Text.RegularExpressions; + +namespace GVFS.UnitTests.CommandLine +{ + [TestFixture] + public class VersionOutputTests + { + // Matches "GVFS X.Y.Z.W" with optional "+commitid" suffix + private static readonly Regex VersionPattern = new Regex( + @"^GVFS \d+\.\d+\.\d+\.\d+(\+\S+)?$", + RegexOptions.Compiled); + + [TestCase("version")] + [TestCase("--version")] + public void VersionOutputMatchesExpectedFormat(string arg) + { + RootCommand rootCommand = GVFS.Program.BuildRootCommand(); + + string output; + TextWriter originalOut = Console.Out; + try + { + using (StringWriter sw = new StringWriter()) + { + Console.SetOut(sw); + rootCommand.Parse(new[] { arg }).Invoke(); + output = sw.ToString().Trim(); + } + } + finally + { + Console.SetOut(originalOut); + } + + Assert.That( + VersionPattern.IsMatch(output), + "Expected 'GVFS X.Y.Z.W' format but got: " + output); + } + + [Test] + public void VersionAndDashDashVersionProduceSameOutput() + { + RootCommand rootCommand = GVFS.Program.BuildRootCommand(); + + string versionOutput = CaptureOutput(rootCommand, "version"); + string dashDashOutput = CaptureOutput(rootCommand, "--version"); + + Assert.AreEqual(versionOutput, dashDashOutput); + } + + private static string CaptureOutput(RootCommand rootCommand, string arg) + { + TextWriter originalOut = Console.Out; + try + { + using (StringWriter sw = new StringWriter()) + { + Console.SetOut(sw); + rootCommand.Parse(new[] { arg }).Invoke(); + return sw.ToString().Trim(); + } + } + finally + { + Console.SetOut(originalOut); + } + } + } +} diff --git a/GVFS/GVFS.UnitTests/Common/CacheServerResolverTests.cs b/GVFS/GVFS.UnitTests/Common/CacheServerResolverTests.cs index a90daa8d3..852ecb908 100644 --- a/GVFS/GVFS.UnitTests/Common/CacheServerResolverTests.cs +++ b/GVFS/GVFS.UnitTests/Common/CacheServerResolverTests.cs @@ -1,10 +1,9 @@ -using GVFS.Common; +using GVFS.Common; using GVFS.Common.Git; using GVFS.Common.Http; using GVFS.Tests.Should; using GVFS.UnitTests.Mock.Common; using GVFS.UnitTests.Mock.Git; -using Newtonsoft.Json; using NUnit.Framework; namespace GVFS.UnitTests.Common @@ -217,7 +216,7 @@ private ServerGVFSConfig CreateGVFSConfig() private ServerGVFSConfig CreateDefaultDeserializedGVFSConfig() { - return JsonConvert.DeserializeObject("{}"); + return GVFSJsonOptions.Deserialize("{}"); } private CacheServerResolver CreateResolver(MockGVFSEnlistment enlistment = null) diff --git a/GVFS/GVFS.UnitTests/GVFS.UnitTests.csproj b/GVFS/GVFS.UnitTests/GVFS.UnitTests.csproj index 8c3669baa..0df5f3263 100644 --- a/GVFS/GVFS.UnitTests/GVFS.UnitTests.csproj +++ b/GVFS/GVFS.UnitTests/GVFS.UnitTests.csproj @@ -1,40 +1,29 @@ - + - net471 Exe true + false - + - - - + + + - - - - - - + - - - ProjectedFSLib.dll - PreserveNewest - + @@ -59,3 +48,4 @@ + diff --git a/GVFS/GVFS.UnitTests/Hooks/PostIndexChangedHookTests.cs b/GVFS/GVFS.UnitTests/Hooks/PostIndexChangedHookTests.cs new file mode 100644 index 000000000..23fb9eeed --- /dev/null +++ b/GVFS/GVFS.UnitTests/Hooks/PostIndexChangedHookTests.cs @@ -0,0 +1,178 @@ +using NUnit.Framework; +using System; +using System.Diagnostics; +using System.IO; + +namespace GVFS.UnitTests.Hooks +{ + [TestFixture] + public class PostIndexChangedHookTests + { + // Exit code from common.h ReturnCode::NotInGVFSEnlistment. + // The hook dies with this code when it can't find a .gvfs folder. + private const int NotInGVFSEnlistment = 3; + + // The hook exe is built to the same output root as the test runner. + // Walk up from the unit test output dir to find the hook exe under + // the shared build output tree. + private static readonly string HookExePath = FindHookExe(); + + private static string FindHookExe() + { + // Test runner lives at: out\GVFS.UnitTests\bin\Debug\net471\win-x64\ + // Hook exe lives at: out\GVFS.PostIndexChangedHook\bin\x64\Debug\ + string testDir = Path.GetDirectoryName(Environment.ProcessPath); + string outDir = Path.GetFullPath(Path.Combine(testDir, "..", "..", "..", "..", "..")); + string hookPath = Path.Combine(outDir, "GVFS.PostIndexChangedHook", "bin", "x64", "Debug", "GVFS.PostIndexChangedHook.exe"); + + // Also check via VFS_OUTDIR if available + if (!File.Exists(hookPath)) + { + string vfsOutDir = Environment.GetEnvironmentVariable("VFS_OUTDIR"); + if (!string.IsNullOrEmpty(vfsOutDir)) + { + hookPath = Path.Combine(vfsOutDir, "GVFS.PostIndexChangedHook", "bin", "x64", "Debug", "GVFS.PostIndexChangedHook.exe"); + } + } + + return hookPath; + } + + [SetUp] + public void EnsureHookExists() + { + if (!File.Exists(HookExePath)) + { + Assert.Ignore($"Hook exe not found at {HookExePath} — build the full solution first."); + } + } + + /// + /// When GIT_INDEX_FILE points to a non-canonical (temp) index, + /// the hook should exit immediately with code 0 without trying + /// to connect to the GVFS pipe. + /// + [TestCase("C:\\repo\\.git\\tmp_index_1234", "C:\\repo\\.git")] + [TestCase("/repo/.git/some_other_index", "/repo/.git")] + [TestCase("D:\\src\\.git\\index.lock", "D:\\src\\.git")] + [TestCase("C:\\tmp\\scratch_index", "C:\\repo\\.git")] + public void SkipsNotification_WhenIndexIsNonCanonical(string indexFile, string gitDir) + { + int exitCode = RunHook(indexFile, gitDir); + Assert.AreEqual(0, exitCode, "Hook should exit 0 (skip) for non-canonical index"); + } + + /// + /// When GIT_INDEX_FILE matches the canonical $GIT_DIR/index, + /// the hook should NOT skip — it should proceed and attempt the + /// pipe connection. Outside a GVFS mount (WorkingDirectory is + /// %TEMP%), the hook fails with NotInGVFSEnlistment, proving + /// the guard did not fire. + /// + [TestCase("C:\\repo\\.git\\index", "C:\\repo\\.git")] + [TestCase("C:\\repo\\.git/index", "C:\\repo\\.git\\")] + public void DoesNotSkip_WhenIndexIsCanonical(string indexFile, string gitDir) + { + int exitCode = RunHook(indexFile, gitDir); + Assert.AreEqual(NotInGVFSEnlistment, exitCode, + "Hook should NOT skip for canonical index (NotInGVFSEnlistment = guard didn't fire)"); + } + + /// + /// When GIT_INDEX_FILE is not set at all, the hook should NOT + /// skip — this is the normal case where git writes the default index. + /// + [Test] + public void DoesNotSkip_WhenGitIndexFileNotSet() + { + int exitCode = RunHook(null, "C:\\repo\\.git"); + Assert.AreEqual(NotInGVFSEnlistment, exitCode, + "Hook should NOT skip when GIT_INDEX_FILE is unset"); + } + + /// + /// When GIT_INDEX_FILE is set but GIT_DIR is empty/missing, + /// the hook should NOT skip — err on the side of correctness + /// when the environment is unexpected. + /// + [TestCase("C:\\repo\\.git\\tmp_index", null)] + [TestCase("C:\\repo\\.git\\tmp_index", "")] + public void DoesNotSkip_WhenGitDirMissing(string indexFile, string gitDir) + { + int exitCode = RunHook(indexFile, gitDir); + Assert.AreEqual(NotInGVFSEnlistment, exitCode, + "Hook should NOT skip when GIT_DIR is absent — err on the side of correctness"); + } + + /// + /// Case-insensitive matching: mixed-case paths that resolve to + /// the canonical index should NOT skip. + /// + [Test] + public void DoesNotSkip_WhenCanonicalPathDiffersOnlyInCase() + { + int exitCode = RunHook("C:\\Repo\\.GIT\\INDEX", "C:\\Repo\\.GIT"); + Assert.AreEqual(NotInGVFSEnlistment, exitCode, + "Case-insensitive canonical match should NOT skip"); + } + + /// + /// Separator normalization: forward vs backslash in canonical + /// path should still match. + /// + [Test] + public void SkipsNotification_ForwardSlashTempIndex() + { + int exitCode = RunHook("C:/repo/.git/tmp_idx", "C:\\repo\\.git"); + Assert.AreEqual(0, exitCode, "Forward-slash temp index should still be detected as non-canonical"); + } + + private int RunHook(string gitIndexFile, string gitDir) + { + ProcessStartInfo psi = new ProcessStartInfo + { + FileName = HookExePath, + Arguments = "1 0", + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardError = true, + RedirectStandardOutput = true, + + // Run outside any GVFS enlistment so the pipe lookup + // fails predictably with NotInGVFSEnlistment. + WorkingDirectory = Path.GetTempPath(), + }; + + // Set or remove env vars + if (gitIndexFile != null) + { + psi.Environment["GIT_INDEX_FILE"] = gitIndexFile; + } + else + { + psi.Environment.Remove("GIT_INDEX_FILE"); + } + + if (gitDir != null) + { + psi.Environment["GIT_DIR"] = gitDir; + } + else + { + psi.Environment.Remove("GIT_DIR"); + } + + using (Process process = Process.Start(psi)) + { + process.WaitForExit(5000); + if (!process.HasExited) + { + process.Kill(); + Assert.Fail("Hook process timed out (5s) — likely blocked on pipe connect"); + } + + return process.ExitCode; + } + } + } +} diff --git a/GVFS/GVFS.UnitTests/Mock/Common/MockTracer.cs b/GVFS/GVFS.UnitTests/Mock/Common/MockTracer.cs index a4137fcfc..c04be4204 100644 --- a/GVFS/GVFS.UnitTests/Mock/Common/MockTracer.cs +++ b/GVFS/GVFS.UnitTests/Mock/Common/MockTracer.cs @@ -1,5 +1,5 @@ +using GVFS.Common; using GVFS.Common.Tracing; -using Newtonsoft.Json; using System; using System.Collections.Generic; using System.Threading; @@ -54,7 +54,7 @@ public void RelatedInfo(string message) public void RelatedInfo(EventMetadata metadata, string message) { metadata[TracingConstants.MessageKey.InfoMessage] = message; - this.RelatedInfoEvents.Add(JsonConvert.SerializeObject(metadata)); + this.RelatedInfoEvents.Add(GVFSJsonOptions.Serialize(metadata)); } public void RelatedInfo(string format, params object[] args) @@ -67,7 +67,7 @@ public void RelatedWarning(EventMetadata metadata, string message) if (metadata != null) { metadata[TracingConstants.MessageKey.WarningMessage] = message; - this.RelatedWarningEvents.Add(JsonConvert.SerializeObject(metadata)); + this.RelatedWarningEvents.Add(GVFSJsonOptions.Serialize(metadata)); } else if (message != null) { @@ -93,7 +93,7 @@ public void RelatedWarning(string format, params object[] args) public void RelatedError(EventMetadata metadata, string message) { metadata[TracingConstants.MessageKey.ErrorMessage] = message; - this.RelatedErrorEvents.Add(JsonConvert.SerializeObject(metadata)); + this.RelatedErrorEvents.Add(GVFSJsonOptions.Serialize(metadata)); } public void RelatedError(EventMetadata metadata, string message, Keywords keyword) diff --git a/GVFS/GVFS.UnitTests/Prefetch/DiffHelperTests.cs b/GVFS/GVFS.UnitTests/Prefetch/DiffHelperTests.cs index 09647738a..0799ad11b 100644 --- a/GVFS/GVFS.UnitTests/Prefetch/DiffHelperTests.cs +++ b/GVFS/GVFS.UnitTests/Prefetch/DiffHelperTests.cs @@ -6,6 +6,7 @@ using GVFS.UnitTests.Mock.Common; using GVFS.UnitTests.Mock.Git; using NUnit.Framework; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -167,7 +168,7 @@ public void DetectsFailuresInLsTree() private static string GetDataPath(string fileName) { - string workingDirectory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + string workingDirectory = Path.GetDirectoryName(Environment.ProcessPath); return Path.Combine(workingDirectory, "Data", fileName); } } diff --git a/GVFS/GVFS.UnitTests/Tracing/TelemetryDaemonEventListenerTests.cs b/GVFS/GVFS.UnitTests/Tracing/TelemetryDaemonEventListenerTests.cs index 0789f9226..f3024bfaa 100644 --- a/GVFS/GVFS.UnitTests/Tracing/TelemetryDaemonEventListenerTests.cs +++ b/GVFS/GVFS.UnitTests/Tracing/TelemetryDaemonEventListenerTests.cs @@ -1,7 +1,8 @@ using System.Collections.Generic; +using System.Linq; +using System.Text.Json; using GVFS.Common.Tracing; using GVFS.Tests.Should; -using Newtonsoft.Json; using NUnit.Framework; namespace GVFS.UnitTests.Tracing @@ -22,22 +23,6 @@ public void TraceMessageDataIsCorrectFormat() const string gitCommandSessionId = "test_sessionId"; const string payload = "test-payload"; - Dictionary expectedDict = new Dictionary - { - ["version"] = vfsVersion, - ["providerName"] = providerName, - ["eventName"] = eventName, - ["eventLevel"] = (int)level, - ["eventOpcode"] = (int)opcode, - ["payload"] = new Dictionary - { - ["enlistmentId"] = enlistmentId, - ["mountId"] = mountId, - ["gitCommandSessionId"] = gitCommandSessionId, - ["json"] = payload, - }, - }; - TelemetryDaemonEventListener.PipeMessage message = new TelemetryDaemonEventListener.PipeMessage { Version = vfsVersion, @@ -56,22 +41,23 @@ public void TraceMessageDataIsCorrectFormat() string messageJson = message.ToJson(); - Dictionary actualDict = JsonConvert.DeserializeObject>(messageJson); - - actualDict.Count.ShouldEqual(expectedDict.Count); - actualDict["version"].ShouldEqual(expectedDict["version"]); - actualDict["providerName"].ShouldEqual(expectedDict["providerName"]); - actualDict["eventName"].ShouldEqual(expectedDict["eventName"]); - actualDict["eventLevel"].ShouldEqual(expectedDict["eventLevel"]); - actualDict["eventOpcode"].ShouldEqual(expectedDict["eventOpcode"]); + using (JsonDocument doc = JsonDocument.Parse(messageJson)) + { + JsonElement root = doc.RootElement; + root.EnumerateObject().Count().ShouldEqual(6); + root.GetProperty("version").GetString().ShouldEqual(vfsVersion); + root.GetProperty("providerName").GetString().ShouldEqual(providerName); + root.GetProperty("eventName").GetString().ShouldEqual(eventName); + root.GetProperty("eventLevel").GetInt32().ShouldEqual((int)level); + root.GetProperty("eventOpcode").GetInt32().ShouldEqual((int)opcode); - Dictionary expectedPayloadDict = (Dictionary)expectedDict["payload"]; - Dictionary actualPayloadDict = JsonConvert.DeserializeObject>(actualDict["payload"].ToString()); - actualPayloadDict.Count.ShouldEqual(expectedPayloadDict.Count); - actualPayloadDict["enlistmentId"].ShouldEqual(expectedPayloadDict["enlistmentId"]); - actualPayloadDict["mountId"].ShouldEqual(expectedPayloadDict["mountId"]); - actualPayloadDict["gitCommandSessionId"].ShouldEqual(expectedPayloadDict["gitCommandSessionId"]); - actualPayloadDict["json"].ShouldEqual(expectedPayloadDict["json"]); + JsonElement payloadElement = root.GetProperty("payload"); + payloadElement.EnumerateObject().Count().ShouldEqual(4); + payloadElement.GetProperty("enlistmentId").GetString().ShouldEqual(enlistmentId); + payloadElement.GetProperty("mountId").GetString().ShouldEqual(mountId); + payloadElement.GetProperty("gitCommandSessionId").GetString().ShouldEqual(gitCommandSessionId); + payloadElement.GetProperty("json").GetString().ShouldEqual(payload); + } } } } diff --git a/GVFS/GVFS.UnitTests/Virtualization/FileSystemCallbacksTests.cs b/GVFS/GVFS.UnitTests/Virtualization/FileSystemCallbacksTests.cs index 6f027f83c..6ebd817fd 100644 --- a/GVFS/GVFS.UnitTests/Virtualization/FileSystemCallbacksTests.cs +++ b/GVFS/GVFS.UnitTests/Virtualization/FileSystemCallbacksTests.cs @@ -1,4 +1,4 @@ -using GVFS.Common; +using GVFS.Common; using GVFS.Common.Database; using GVFS.Common.NamedPipes; using GVFS.Common.Tracing; @@ -13,7 +13,6 @@ using GVFS.Virtualization; using GVFS.Virtualization.Background; using Moq; -using Newtonsoft.Json; using NUnit.Framework; using System; using System.Collections.Generic; @@ -163,8 +162,8 @@ public void GetMetadataForHeartBeatDoesSetsEventLevelToInformationalWhenPlacehol metadata.Count.ShouldEqual(8); metadata.ContainsKey("FilePlaceholderCreation").ShouldBeTrue(); metadata.TryGetValue("FilePlaceholderCreation", out object fileNestedMetadata); - JsonConvert.SerializeObject(fileNestedMetadata).ShouldContain("\"ProcessName1\":\"GVFS.UnitTests.exe\""); - JsonConvert.SerializeObject(fileNestedMetadata).ShouldContain("\"ProcessCount1\":1"); + GVFSJsonOptions.Serialize(fileNestedMetadata).ShouldContain("\"ProcessName1\":\"GVFS.UnitTests.exe\""); + GVFSJsonOptions.Serialize(fileNestedMetadata).ShouldContain("\"ProcessCount1\":1"); metadata.ShouldContain("ModifiedPathsCount", 1); metadata.ShouldContain("FilePlaceholderCount", 1); metadata.ShouldContain("FolderPlaceholderCount", 0); @@ -188,16 +187,16 @@ public void GetMetadataForHeartBeatDoesSetsEventLevelToInformationalWhenPlacehol // Only processes that have created placeholders since the last heartbeat should be named metadata.ContainsKey("FilePlaceholderCreation").ShouldBeTrue(); metadata.TryGetValue("FilePlaceholderCreation", out object fileNestedMetadata2); - JsonConvert.SerializeObject(fileNestedMetadata2).ShouldContain("\"ProcessName1\":\"GVFS.UnitTests.exe2\""); - JsonConvert.SerializeObject(fileNestedMetadata2).ShouldContain("\"ProcessCount1\":2"); + GVFSJsonOptions.Serialize(fileNestedMetadata2).ShouldContain("\"ProcessName1\":\"GVFS.UnitTests.exe2\""); + GVFSJsonOptions.Serialize(fileNestedMetadata2).ShouldContain("\"ProcessCount1\":2"); metadata.ContainsKey("FolderPlaceholderCreation").ShouldBeTrue(); metadata.TryGetValue("FolderPlaceholderCreation", out object folderNestedMetadata2); - JsonConvert.SerializeObject(folderNestedMetadata2).ShouldContain("\"ProcessName1\":\"GVFS.UnitTests.exe2\""); - JsonConvert.SerializeObject(folderNestedMetadata2).ShouldContain("\"ProcessCount1\":1"); + GVFSJsonOptions.Serialize(folderNestedMetadata2).ShouldContain("\"ProcessName1\":\"GVFS.UnitTests.exe2\""); + GVFSJsonOptions.Serialize(folderNestedMetadata2).ShouldContain("\"ProcessCount1\":1"); metadata.ContainsKey("FilePlaceholdersHydrated").ShouldBeTrue(); metadata.TryGetValue("FilePlaceholdersHydrated", out object hydrationNestedMetadata2); - JsonConvert.SerializeObject(hydrationNestedMetadata2).ShouldContain("\"ProcessName1\":\"GVFS.UnitTests.exe2\""); - JsonConvert.SerializeObject(hydrationNestedMetadata2).ShouldContain("\"ProcessCount1\":1"); + GVFSJsonOptions.Serialize(hydrationNestedMetadata2).ShouldContain("\"ProcessName1\":\"GVFS.UnitTests.exe2\""); + GVFSJsonOptions.Serialize(hydrationNestedMetadata2).ShouldContain("\"ProcessCount1\":1"); metadata.ShouldContain("ModifiedPathsCount", 1); metadata.ShouldContain("FilePlaceholderCount", 3); metadata.ShouldContain("FolderPlaceholderCount", 1); diff --git a/GVFS/GVFS.UnitTests/Windows/Mock/MockVirtualizationInstance.cs b/GVFS/GVFS.UnitTests/Windows/Mock/MockVirtualizationInstance.cs index 86d8737f3..16659ddda 100644 --- a/GVFS/GVFS.UnitTests/Windows/Mock/MockVirtualizationInstance.cs +++ b/GVFS/GVFS.UnitTests/Windows/Mock/MockVirtualizationInstance.cs @@ -31,9 +31,13 @@ public MockVirtualizationInstance() public ConcurrentHashSet CreatedPlaceholders { get; private set; } + public Guid VirtualizationInstanceId { get; set; } + + public int PlaceholderIdLength { get; set; } + public CancelCommandCallback OnCancelCommand { get; set; } - public IRequiredCallbacks requiredCallbacks { get; set; } + public IRequiredCallbacks RequiredCallbacks { get; set; } public NotifyFileOpenedCallback OnNotifyFileOpened { get; set; } public NotifyNewFileCreatedCallback OnNotifyNewFileCreated { get; set; } public NotifyFileOverwrittenCallback OnNotifyFileOverwritten { get; set; } @@ -63,7 +67,7 @@ public HResult WriteFileReturnResult public HResult StartVirtualizing(IRequiredCallbacks requiredCallbacks) { - this.requiredCallbacks = requiredCallbacks; + this.RequiredCallbacks = requiredCallbacks; return HResult.Ok; } diff --git a/GVFS/GVFS.UnitTests/Windows/Mock/WindowsFileSystemVirtualizerTester.cs b/GVFS/GVFS.UnitTests/Windows/Mock/WindowsFileSystemVirtualizerTester.cs index 95da8daa4..d25b1131b 100644 --- a/GVFS/GVFS.UnitTests/Windows/Mock/WindowsFileSystemVirtualizerTester.cs +++ b/GVFS/GVFS.UnitTests/Windows/Mock/WindowsFileSystemVirtualizerTester.cs @@ -30,7 +30,7 @@ public void InvokeGetFileDataCallback(HResult expectedResult = HResult.Pending, providerId = WindowsFileSystemVirtualizer.PlaceholderVersionId; } - this.MockVirtualization.requiredCallbacks.GetFileDataCallback( + this.MockVirtualization.RequiredCallbacks.GetFileDataCallback( commandId: 1, relativePath: "test.txt", byteOffset: byteOffset, diff --git a/GVFS/GVFS.UnitTests/Windows/ServiceUI/GVFSToastRequestHandlerTests.cs b/GVFS/GVFS.UnitTests/Windows/ServiceUI/GVFSToastRequestHandlerTests.cs deleted file mode 100644 index 34e7073e9..000000000 --- a/GVFS/GVFS.UnitTests/Windows/ServiceUI/GVFSToastRequestHandlerTests.cs +++ /dev/null @@ -1,111 +0,0 @@ -using GVFS.Common.NamedPipes; -using GVFS.Service.UI; -using GVFS.UnitTests.Mock.Common; -using Moq; -using NUnit.Framework; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace GVFS.UnitTests.Windows.ServiceUI -{ - [TestFixture] - public class GVFSToastRequestHandlerTests - { - private NamedPipeMessages.Notification.Request request; - private GVFSToastRequestHandler toastHandler; - private Mock mockToastNotifier; - private MockTracer tracer; - - [SetUp] - public void Setup() - { - this.tracer = new MockTracer(); - this.mockToastNotifier = new Mock(MockBehavior.Strict); - this.mockToastNotifier.SetupSet(toastNotifier => toastNotifier.UserResponseCallback = It.IsAny>()).Verifiable(); - this.toastHandler = new GVFSToastRequestHandler(this.mockToastNotifier.Object, this.tracer); - this.request = new NamedPipeMessages.Notification.Request(); - } - - [TestCase] - public void UpgradeToastIsActionableAndContainsVersionInfo() - { - const string version = "1.0.956749.2"; - - this.request.Id = NamedPipeMessages.Notification.Request.Identifier.UpgradeAvailable; - this.request.NewVersion = version; - - this.VerifyToastMessage( - expectedTitle: "New version " + version + " is available", - expectedMessage: "click Upgrade button", - expectedButtonTitle: "Upgrade", - expectedGVFSCmd: "gvfs upgrade --confirm"); - } - - [TestCase] - public void MountFailureToastIsActionableAndContainEnlistmentInfo() - { - const string enlistmentRoot = "D:\\Work\\OS"; - - this.request.Id = NamedPipeMessages.Notification.Request.Identifier.MountFailure; - this.request.Enlistment = enlistmentRoot; - - this.VerifyToastMessage( - expectedTitle: "VFS For Git Automount", - expectedMessage: enlistmentRoot, - expectedButtonTitle: "Retry", - expectedGVFSCmd: "gvfs mount " + enlistmentRoot); - } - - [TestCase] - public void MountStartIsNotActionableAndContainsEnlistmentCount() - { - const int enlistmentCount = 10; - - this.request.Id = NamedPipeMessages.Notification.Request.Identifier.AutomountStart; - this.request.EnlistmentCount = enlistmentCount; - - this.VerifyToastMessage( - expectedTitle: "VFS For Git Automount", - expectedMessage: "mount " + enlistmentCount.ToString() + " VFS For Git repos", - expectedButtonTitle: null, - expectedGVFSCmd: null); - } - - [TestCase] - public void UnknownToastRequestGetsIgnored() - { - this.request.Id = (NamedPipeMessages.Notification.Request.Identifier)10; - this.request.EnlistmentCount = 232; - this.request.Enlistment = "C:\\OS"; - - this.toastHandler.HandleToastRequest(this.tracer, this.request); - - this.mockToastNotifier.Verify( - toastNotifier => toastNotifier.Notify( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny()), - Times.Never()); - } - - private void VerifyToastMessage( - string expectedTitle, - string expectedMessage, - string expectedButtonTitle, - string expectedGVFSCmd) - { - this.mockToastNotifier.Setup(toastNotifier => toastNotifier.Notify( - expectedTitle, - It.Is(message => message.Contains(expectedMessage)), - expectedButtonTitle, - expectedGVFSCmd)); - - this.toastHandler.HandleToastRequest(this.tracer, this.request); - this.mockToastNotifier.VerifyAll(); - } - } -} diff --git a/GVFS/GVFS.UnitTests/Windows/Virtualization/WindowsFileSystemVirtualizerTests.cs b/GVFS/GVFS.UnitTests/Windows/Virtualization/WindowsFileSystemVirtualizerTests.cs index b5a18c0b1..5ca3f4115 100644 --- a/GVFS/GVFS.UnitTests/Windows/Virtualization/WindowsFileSystemVirtualizerTests.cs +++ b/GVFS/GVFS.UnitTests/Windows/Virtualization/WindowsFileSystemVirtualizerTests.cs @@ -163,9 +163,9 @@ public void OnStartDirectoryEnumerationReturnsPendingWhenResultsNotInMemory() { Guid enumerationGuid = Guid.NewGuid(); tester.GitIndexProjection.EnumerationInMemory = false; - tester.MockVirtualization.requiredCallbacks.StartDirectoryEnumerationCallback(1, enumerationGuid, "test", TriggeringProcessId, TriggeringProcessImageFileName).ShouldEqual(HResult.Pending); + tester.MockVirtualization.RequiredCallbacks.StartDirectoryEnumerationCallback(1, enumerationGuid, "test", TriggeringProcessId, TriggeringProcessImageFileName).ShouldEqual(HResult.Pending); tester.MockVirtualization.WaitForCompletionStatus().ShouldEqual(HResult.Ok); - tester.MockVirtualization.requiredCallbacks.EndDirectoryEnumerationCallback(enumerationGuid).ShouldEqual(HResult.Ok); + tester.MockVirtualization.RequiredCallbacks.EndDirectoryEnumerationCallback(enumerationGuid).ShouldEqual(HResult.Ok); } } @@ -176,8 +176,8 @@ public void OnStartDirectoryEnumerationReturnsSuccessWhenResultsInMemory() { Guid enumerationGuid = Guid.NewGuid(); tester.GitIndexProjection.EnumerationInMemory = true; - tester.MockVirtualization.requiredCallbacks.StartDirectoryEnumerationCallback(1, enumerationGuid, "test", TriggeringProcessId, TriggeringProcessImageFileName).ShouldEqual(HResult.Ok); - tester.MockVirtualization.requiredCallbacks.EndDirectoryEnumerationCallback(enumerationGuid).ShouldEqual(HResult.Ok); + tester.MockVirtualization.RequiredCallbacks.StartDirectoryEnumerationCallback(1, enumerationGuid, "test", TriggeringProcessId, TriggeringProcessImageFileName).ShouldEqual(HResult.Ok); + tester.MockVirtualization.RequiredCallbacks.EndDirectoryEnumerationCallback(enumerationGuid).ShouldEqual(HResult.Ok); } } @@ -186,7 +186,7 @@ public void GetPlaceholderInformationHandlerPathNotProjected() { using (WindowsFileSystemVirtualizerTester tester = new WindowsFileSystemVirtualizerTester(this.Repo)) { - tester.MockVirtualization.requiredCallbacks.GetPlaceholderInfoCallback(1, "doesNotExist", TriggeringProcessId, TriggeringProcessImageFileName).ShouldEqual(HResult.FileNotFound); + tester.MockVirtualization.RequiredCallbacks.GetPlaceholderInfoCallback(1, "doesNotExist", TriggeringProcessId, TriggeringProcessImageFileName).ShouldEqual(HResult.FileNotFound); } } @@ -195,7 +195,7 @@ public void GetPlaceholderInformationHandlerPathProjected() { using (WindowsFileSystemVirtualizerTester tester = new WindowsFileSystemVirtualizerTester(this.Repo)) { - tester.MockVirtualization.requiredCallbacks.GetPlaceholderInfoCallback(1, "test.txt", TriggeringProcessId, TriggeringProcessImageFileName).ShouldEqual(HResult.Pending); + tester.MockVirtualization.RequiredCallbacks.GetPlaceholderInfoCallback(1, "test.txt", TriggeringProcessId, TriggeringProcessImageFileName).ShouldEqual(HResult.Pending); tester.MockVirtualization.WaitForCompletionStatus().ShouldEqual(HResult.Ok); tester.MockVirtualization.CreatedPlaceholders.ShouldContain(entry => entry == "test.txt"); tester.GitIndexProjection.PlaceholdersCreated.ShouldContain(entry => entry == "test.txt"); @@ -218,7 +218,7 @@ public void GetPlaceholderInformationHandlerCancelledBeforeSchedulingAsync() tester.GitIndexProjection.UnblockIsPathProjected(); }); - tester.MockVirtualization.requiredCallbacks.GetPlaceholderInfoCallback(1, "test.txt", TriggeringProcessId, TriggeringProcessImageFileName).ShouldEqual(HResult.Pending); + tester.MockVirtualization.RequiredCallbacks.GetPlaceholderInfoCallback(1, "test.txt", TriggeringProcessId, TriggeringProcessImageFileName).ShouldEqual(HResult.Pending); // Cancelling before GetPlaceholderInformation has registered the command results in placeholders being created tester.MockVirtualization.WaitForPlaceholderCreate(); @@ -234,7 +234,7 @@ public void GetPlaceholderInformationHandlerCancelledDuringAsyncCallback() using (WindowsFileSystemVirtualizerTester tester = new WindowsFileSystemVirtualizerTester(this.Repo)) { tester.GitIndexProjection.BlockGetProjectedFileInfo(willWaitForRequest: true); - tester.MockVirtualization.requiredCallbacks.GetPlaceholderInfoCallback(1, "test.txt", TriggeringProcessId, TriggeringProcessImageFileName).ShouldEqual(HResult.Pending); + tester.MockVirtualization.RequiredCallbacks.GetPlaceholderInfoCallback(1, "test.txt", TriggeringProcessId, TriggeringProcessImageFileName).ShouldEqual(HResult.Pending); tester.GitIndexProjection.WaitForGetProjectedFileInfo(); tester.MockVirtualization.OnCancelCommand(1); tester.GitIndexProjection.UnblockGetProjectedFileInfo(); @@ -257,7 +257,7 @@ public void GetPlaceholderInformationHandlerCancelledDuringNetworkRequest() MockTracer mockTracker = this.Repo.Context.Tracer as MockTracer; mockTracker.WaitRelatedEventName = "GetPlaceholderInformationAsyncHandler_GetProjectedFileInfo_Cancelled"; tester.GitIndexProjection.ThrowOperationCanceledExceptionOnProjectionRequest = true; - tester.MockVirtualization.requiredCallbacks.GetPlaceholderInfoCallback(1, "test.txt", TriggeringProcessId, TriggeringProcessImageFileName).ShouldEqual(HResult.Pending); + tester.MockVirtualization.RequiredCallbacks.GetPlaceholderInfoCallback(1, "test.txt", TriggeringProcessId, TriggeringProcessImageFileName).ShouldEqual(HResult.Pending); // Cancelling in the middle of GetPlaceholderInformation in the middle of a network request should not result in placeholder // getting created diff --git a/GVFS/GVFS.Virtualization/Background/FileSystemTask.cs b/GVFS/GVFS.Virtualization/Background/FileSystemTask.cs index 36750fcfd..675c3f086 100644 --- a/GVFS/GVFS.Virtualization/Background/FileSystemTask.cs +++ b/GVFS/GVFS.Virtualization/Background/FileSystemTask.cs @@ -1,4 +1,4 @@ -using Newtonsoft.Json; +using System.Text.Json; namespace GVFS.Virtualization.Background { @@ -133,7 +133,7 @@ public static FileSystemTask OnPlaceholderCreationsBlockedForGit() public override string ToString() { - return JsonConvert.SerializeObject(this); + return JsonSerializer.Serialize(this, VirtualizationJsonContext.Default.FileSystemTask); } } } diff --git a/GVFS/GVFS.Virtualization/FileSystemCallbacks.cs b/GVFS/GVFS.Virtualization/FileSystemCallbacks.cs index 078b403f5..ab2ff36d7 100644 --- a/GVFS/GVFS.Virtualization/FileSystemCallbacks.cs +++ b/GVFS/GVFS.Virtualization/FileSystemCallbacks.cs @@ -695,8 +695,12 @@ public void OnPlaceholderFileCreated(string relativePath, string sha, string tri // Note: Because OnPlaceholderFileCreated is not synchronized on all platforms it is possible that GVFS will double count // the creation of file placeholders if multiple requests for the same file are received at the same time on different // threads. + // + // triggeringProcessImageFileName can be null when ProjFS reports a triggering process ID of 0 (e.g. kernel or + // system-level operations). The ProjFS managed API may pass null for the image file name in AOT builds. + // ConcurrentDictionary does not allow null keys, so fall back to a sentinel value. this.filePlaceHolderCreationCount.AddOrUpdate( - triggeringProcessImageFileName, + triggeringProcessImageFileName ?? string.Empty, (imageName) => { return new PlaceHolderCreateCounter(); }, (key, oldCount) => { oldCount.Increment(); return oldCount; }); } @@ -711,7 +715,7 @@ public void OnPlaceholderFolderCreated(string relativePath, string triggeringPro this.GitIndexProjection.OnPlaceholderFolderCreated(relativePath); this.folderPlaceHolderCreationCount.AddOrUpdate( - triggeringProcessImageFileName, + triggeringProcessImageFileName ?? string.Empty, (imageName) => { return new PlaceHolderCreateCounter(); }, (key, oldCount) => { oldCount.Increment(); return oldCount; }); } @@ -724,7 +728,7 @@ public void OnPlaceholderFolderExpanded(string relativePath) public void OnPlaceholderFileHydrated(string triggeringProcessImageFileName) { this.fileHydrationCount.AddOrUpdate( - triggeringProcessImageFileName, + triggeringProcessImageFileName ?? string.Empty, (imageName) => { return new PlaceHolderCreateCounter(); }, (key, oldCount) => { oldCount.Increment(); return oldCount; }); } diff --git a/GVFS/GVFS.Virtualization/GVFS.Virtualization.csproj b/GVFS/GVFS.Virtualization/GVFS.Virtualization.csproj index 91772d269..9cdd66d42 100644 --- a/GVFS/GVFS.Virtualization/GVFS.Virtualization.csproj +++ b/GVFS/GVFS.Virtualization/GVFS.Virtualization.csproj @@ -1,7 +1,6 @@ - net471 true @@ -10,8 +9,8 @@ - - + + diff --git a/GVFS/GVFS.Virtualization/VirtualizationJsonContext.cs b/GVFS/GVFS.Virtualization/VirtualizationJsonContext.cs new file mode 100644 index 000000000..cd1898ed4 --- /dev/null +++ b/GVFS/GVFS.Virtualization/VirtualizationJsonContext.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Serialization; +using GVFS.Virtualization.Background; + +namespace GVFS.Virtualization +{ + [JsonSerializable(typeof(FileSystemTask))] + internal partial class VirtualizationJsonContext : JsonSerializerContext + { + } +} diff --git a/GVFS/GVFS/CommandLine/CacheServerVerb.cs b/GVFS/GVFS/CommandLine/CacheServerVerb.cs index 9fedad0b0..2e0735b76 100644 --- a/GVFS/GVFS/CommandLine/CacheServerVerb.cs +++ b/GVFS/GVFS/CommandLine/CacheServerVerb.cs @@ -1,4 +1,3 @@ -using CommandLine; using GVFS.Common; using GVFS.Common.Http; using GVFS.Common.Tracing; @@ -8,27 +7,46 @@ namespace GVFS.CommandLine { - [Verb(CacheVerbName, HelpText = "Manages the cache server configuration for an existing repo.")] public class CacheServerVerb : GVFSVerb.ForExistingEnlistment { private const string CacheVerbName = "cache-server"; - [Option( - "set", - Default = null, - Required = false, - HelpText = "Sets the cache server to the supplied name or url")] public string CacheToSet { get; set; } - [Option("get", Required = false, HelpText = "Outputs the current cache server information. This is the default.")] public bool OutputCurrentInfo { get; set; } - [Option( - "list", - Required = false, - HelpText = "List available cache servers for the remote repo")] public bool ListCacheServers { get; set; } + public static System.CommandLine.Command CreateCommand() + { + System.CommandLine.Command cmd = new System.CommandLine.Command("cache-server", "Manages the cache server configuration for an existing repo."); + + System.CommandLine.Argument enlistmentArg = GVFSVerb.CreateEnlistmentPathArgument(); + cmd.Add(enlistmentArg); + + System.CommandLine.Option setOption = new System.CommandLine.Option("--set") { Description = "Sets the cache server to the supplied name or url" }; + cmd.Add(setOption); + + System.CommandLine.Option getOption = new System.CommandLine.Option("--get") { Description = "Outputs the current cache server information. This is the default." }; + cmd.Add(getOption); + + System.CommandLine.Option listOption = new System.CommandLine.Option("--list") { Description = "List available cache servers for the remote repo" }; + cmd.Add(listOption); + + System.CommandLine.Option internalOption = GVFSVerb.CreateInternalParametersOption(); + cmd.Add(internalOption); + + GVFSVerb.SetActionForVerbWithEnlistment(cmd, enlistmentArg, internalOption, defaultEnlistmentPathToCwd: true, + (verb, result) => + { + verb.CacheToSet = result.GetValue(setOption); + verb.OutputCurrentInfo = result.GetValue(getOption); + verb.ListCacheServers = result.GetValue(listOption); + }); + + return cmd; + } + protected override string VerbName { get { return CacheVerbName; } diff --git a/GVFS/GVFS/CommandLine/CacheVerb.cs b/GVFS/GVFS/CommandLine/CacheVerb.cs index 70c8a65fd..b576d4275 100644 --- a/GVFS/GVFS/CommandLine/CacheVerb.cs +++ b/GVFS/GVFS/CommandLine/CacheVerb.cs @@ -1,4 +1,3 @@ -using CommandLine; using GVFS.Common; using GVFS.Common.FileSystem; using GVFS.Common.Tracing; @@ -8,7 +7,6 @@ namespace GVFS.CommandLine { - [Verb(CacheVerb.CacheVerbName, HelpText = "Display information about the GVFS shared object cache")] public class CacheVerb : GVFSVerb.ForExistingEnlistment { private const string CacheVerbName = "cache"; @@ -17,6 +15,21 @@ public CacheVerb() { } + public static System.CommandLine.Command CreateCommand() + { + System.CommandLine.Command cmd = new System.CommandLine.Command("cache", "Display information about the GVFS shared object cache"); + + System.CommandLine.Argument enlistmentArg = GVFSVerb.CreateEnlistmentPathArgument(); + cmd.Add(enlistmentArg); + + System.CommandLine.Option internalOption = GVFSVerb.CreateInternalParametersOption(); + cmd.Add(internalOption); + + GVFSVerb.SetActionForVerbWithEnlistment(cmd, enlistmentArg, internalOption, defaultEnlistmentPathToCwd: true); + + return cmd; + } + protected override string VerbName { get { return CacheVerbName; } diff --git a/GVFS/GVFS/CommandLine/CloneVerb.cs b/GVFS/GVFS/CommandLine/CloneVerb.cs index bd37c7d4b..e64749d8c 100644 --- a/GVFS/GVFS/CommandLine/CloneVerb.cs +++ b/GVFS/GVFS/CommandLine/CloneVerb.cs @@ -1,4 +1,3 @@ -using CommandLine; using GVFS.Common; using GVFS.Common.FileSystem; using GVFS.Common.Git; @@ -15,67 +14,99 @@ namespace GVFS.CommandLine { - [Verb(CloneVerb.CloneVerbName, HelpText = "Clone a git repo and mount it as a GVFS virtual repo")] public class CloneVerb : GVFSVerb { private const string CloneVerbName = "clone"; - [Value( - 0, - Required = true, - MetaName = "Repository URL", - HelpText = "The url of the repo")] public string RepositoryURL { get; set; } - [Value( - 1, - Required = false, - Default = "", - MetaName = "Enlistment Root Path", - HelpText = "Full or relative path to the GVFS enlistment root")] public override string EnlistmentRootPathParameter { get; set; } - [Option( - "cache-server-url", - Required = false, - Default = null, - HelpText = "The url or friendly name of the cache server")] public string CacheServerUrl { get; set; } - [Option( - 'b', - "branch", - Required = false, - HelpText = "Branch to checkout after clone")] public string Branch { get; set; } - [Option( - "single-branch", - Required = false, - Default = false, - HelpText = "Use this option to only download metadata for the branch that will be checked out")] public bool SingleBranch { get; set; } - [Option( - "no-mount", - Required = false, - Default = false, - HelpText = "Use this option to only clone, but not mount the repo")] public bool NoMount { get; set; } - [Option( - "no-prefetch", - Required = false, - Default = false, - HelpText = "Use this option to not prefetch commits after clone")] public bool NoPrefetch { get; set; } - [Option( - "local-cache-path", - Required = false, - HelpText = "Use this option to override the path for the local GVFS cache.")] public string LocalCacheRoot { get; set; } + public static System.CommandLine.Command CreateCommand() + { + System.CommandLine.Command cmd = new System.CommandLine.Command("clone", "Clone a git repo and mount it as a GVFS virtual repo"); + + System.CommandLine.Argument repoUrlArg = new System.CommandLine.Argument("repository-url") + { + Description = "The url of the repo", + Arity = System.CommandLine.ArgumentArity.ExactlyOne, + }; + cmd.Add(repoUrlArg); + + System.CommandLine.Argument enlistmentArg = new System.CommandLine.Argument("enlistment-root-path") + { + Description = "Full or relative path to the GVFS enlistment root", + Arity = System.CommandLine.ArgumentArity.ZeroOrOne, + DefaultValueFactory = (_) => "", + }; + cmd.Add(enlistmentArg); + + System.CommandLine.Option cacheServerOption = new System.CommandLine.Option("--cache-server-url") { Description = "The url or friendly name of the cache server" }; + cmd.Add(cacheServerOption); + + System.CommandLine.Option branchOption = new System.CommandLine.Option("--branch", new[] { "-b" }) { Description = "Branch to checkout after clone" }; + cmd.Add(branchOption); + + System.CommandLine.Option singleBranchOption = new System.CommandLine.Option("--single-branch") { Description = "Use this option to only download metadata for the branch that will be checked out" }; + cmd.Add(singleBranchOption); + + System.CommandLine.Option noMountOption = new System.CommandLine.Option("--no-mount") { Description = "Use this option to only clone, but not mount the repo" }; + cmd.Add(noMountOption); + + System.CommandLine.Option noPrefetchOption = new System.CommandLine.Option("--no-prefetch") { Description = "Use this option to not prefetch commits after clone" }; + cmd.Add(noPrefetchOption); + + System.CommandLine.Option localCacheOption = new System.CommandLine.Option("--local-cache-path") { Description = "Use this option to override the path for the local GVFS cache." }; + cmd.Add(localCacheOption); + + System.CommandLine.Option internalOption = GVFSVerb.CreateInternalParametersOption(); + cmd.Add(internalOption); + + cmd.SetAction((System.CommandLine.ParseResult result) => + { + CloneVerb verb = new CloneVerb(); + verb.RepositoryURL = result.GetValue(repoUrlArg); + verb.EnlistmentRootPathParameter = result.GetValue(enlistmentArg) ?? ""; + if (verb.EnlistmentRootPathParameter.StartsWith("-")) + { + Console.Error.WriteLine($"Unrecognized option '{verb.EnlistmentRootPathParameter}'"); + Environment.Exit((int)ReturnCode.ParsingError); + } + + verb.CacheServerUrl = result.GetValue(cacheServerOption); + verb.Branch = result.GetValue(branchOption); + verb.SingleBranch = result.GetValue(singleBranchOption); + verb.NoMount = result.GetValue(noMountOption); + verb.NoPrefetch = result.GetValue(noPrefetchOption); + verb.LocalCacheRoot = result.GetValue(localCacheOption); + + GVFSVerb.ApplyInternalParameters(verb, result, internalOption); + try + { + verb.Execute(); + } + catch (GVFSVerb.VerbAbortedException) + { + } + + Environment.Exit((int)verb.ReturnCode); + }); + + return cmd; + } + protected override string VerbName { get { return CloneVerbName; } @@ -253,7 +284,7 @@ public override void Execute() { try { - string gvfsExecutable = Assembly.GetExecutingAssembly().Location; + string gvfsExecutable = Environment.ProcessPath; Process.Start(new ProcessStartInfo( fileName: gvfsExecutable, arguments: "prefetch --commits") @@ -395,7 +426,7 @@ private Result TryClone( if (refs == null) { - return new Result("Could not query info/refs from: " + Uri.EscapeUriString(enlistment.RepoUrl)); + return new Result("Could not query info/refs from: " + Uri.EscapeDataString(enlistment.RepoUrl)); } if (this.Branch == null) diff --git a/GVFS/GVFS/CommandLine/ConfigVerb.cs b/GVFS/GVFS/CommandLine/ConfigVerb.cs index 0773e7478..8626b33b1 100644 --- a/GVFS/GVFS/CommandLine/ConfigVerb.cs +++ b/GVFS/GVFS/CommandLine/ConfigVerb.cs @@ -1,44 +1,74 @@ -using CommandLine; using GVFS.Common; using System; using System.Collections.Generic; namespace GVFS.CommandLine { - [Verb(ConfigVerbName, HelpText = "Get and set GVFS options.")] public class ConfigVerb : GVFSVerb.ForNoEnlistment { private const string ConfigVerbName = "config"; private LocalGVFSConfig localConfig; - [Option( - 'l', - "list", - Required = false, - HelpText = "Show all settings")] public bool List { get; set; } - [Option( - 'd', - "delete", - Required = false, - HelpText = "Name of setting to delete")] public string KeyToDelete { get; set; } - [Value( - 0, - Required = false, - MetaName = "Setting name", - HelpText = "Name of setting that is to be set or read")] public string Key { get; set; } - [Value( - 1, - Required = false, - MetaName = "Setting value", - HelpText = "Value of setting to be set")] public string Value { get; set; } + public static System.CommandLine.Command CreateCommand() + { + System.CommandLine.Command cmd = new System.CommandLine.Command("config", "Get and set GVFS options."); + + System.CommandLine.Option listOption = new System.CommandLine.Option("--list", new[] { "-l" }) { Description = "Show all settings" }; + cmd.Add(listOption); + + System.CommandLine.Option deleteOption = new System.CommandLine.Option("--delete", new[] { "-d" }) { Description = "Name of setting to delete" }; + cmd.Add(deleteOption); + + System.CommandLine.Argument keyArg = new System.CommandLine.Argument("setting-name") + { + Description = "Name of setting that is to be set or read", + Arity = System.CommandLine.ArgumentArity.ZeroOrOne, + DefaultValueFactory = (_) => "", + }; + cmd.Add(keyArg); + + System.CommandLine.Argument valueArg = new System.CommandLine.Argument("setting-value") + { + Description = "Value of setting to be set", + Arity = System.CommandLine.ArgumentArity.ZeroOrOne, + DefaultValueFactory = (_) => "", + }; + cmd.Add(valueArg); + + System.CommandLine.Option internalOption = GVFSVerb.CreateInternalParametersOption(); + cmd.Add(internalOption); + + cmd.SetAction((System.CommandLine.ParseResult result) => + { + ConfigVerb verb = new ConfigVerb(); + verb.List = result.GetValue(listOption); + verb.KeyToDelete = result.GetValue(deleteOption); + verb.Key = result.GetValue(keyArg) ?? ""; + verb.Value = result.GetValue(valueArg) ?? ""; + + GVFSVerb.ApplyInternalParameters(verb, result, internalOption); + try + { + verb.Execute(); + } + catch (GVFSVerb.VerbAbortedException) + { + } + + Environment.Exit((int)verb.ReturnCode); + }); + + return cmd; + } + protected override string VerbName { get { return ConfigVerbName; } diff --git a/GVFS/GVFS/CommandLine/DehydrateVerb.cs b/GVFS/GVFS/CommandLine/DehydrateVerb.cs index 5f9702239..2d7b7f958 100644 --- a/GVFS/GVFS/CommandLine/DehydrateVerb.cs +++ b/GVFS/GVFS/CommandLine/DehydrateVerb.cs @@ -1,4 +1,3 @@ -using CommandLine; using GVFS.Common; using GVFS.Common.Database; using GVFS.Common.FileSystem; @@ -17,7 +16,6 @@ namespace GVFS.CommandLine { - [Verb(DehydrateVerb.DehydrateVerbName, HelpText = "EXPERIMENTAL FEATURE - Fully dehydrate a GVFS repo")] public class DehydrateVerb : GVFSVerb.ForExistingEnlistment { private const string DehydrateVerbName = "dehydrate"; @@ -25,40 +23,55 @@ public class DehydrateVerb : GVFSVerb.ForExistingEnlistment private PhysicalFileSystem fileSystem = new PhysicalFileSystem(); - [Option( - "confirm", - Default = false, - Required = false, - HelpText = "Pass in this flag to actually do the dehydrate")] public bool Confirmed { get; set; } - [Option( - "no-status", - Default = false, - Required = false, - HelpText = "Do not require a clean git status when dehydrating. To prevent data loss, this option cannot be combined with --folders option.")] public bool NoStatus { get; set; } - [Option( - "folders", - Default = "", - Required = false, - HelpText = "A semicolon (" + FolderListSeparator + ") delimited list of folders to dehydrate. " - + "Each folder must be relative to the repository root. " - + "When omitted (without --full), all root-level folders are dehydrated.")] public string Folders { get; set; } - [Option( - "full", - Default = false, - Required = false, - HelpText = "Perform a full dehydration that unmounts, backs up the entire src folder, and re-creates the virtualization root from scratch. " - + "Without this flag, the default behavior dehydrates individual folders which is faster and does not require a full unmount.")] public bool Full { get; set; } public string RunningVerbName { get; set; } = DehydrateVerbName; public string ActionName { get; set; } = DehydrateVerbName; + public static System.CommandLine.Command CreateCommand() + { + System.CommandLine.Command cmd = new System.CommandLine.Command("dehydrate", "EXPERIMENTAL FEATURE - Fully dehydrate a GVFS repo"); + + System.CommandLine.Argument enlistmentArg = GVFSVerb.CreateEnlistmentPathArgument(); + cmd.Add(enlistmentArg); + + System.CommandLine.Option confirmOption = new System.CommandLine.Option("--confirm") { Description = "Pass in this flag to actually do the dehydrate" }; + cmd.Add(confirmOption); + + System.CommandLine.Option noStatusOption = new System.CommandLine.Option("--no-status") { Description = "Do not require a clean git status when dehydrating. To prevent data loss, this option cannot be combined with --folders option." }; + cmd.Add(noStatusOption); + + System.CommandLine.Option foldersOption = new System.CommandLine.Option("--folders") + { + Description = "A semicolon (;) delimited list of folders to dehydrate. Each folder must be relative to the repository root. When omitted (without --full), all root-level folders are dehydrated.", + DefaultValueFactory = (_) => "", + }; + cmd.Add(foldersOption); + + System.CommandLine.Option fullOption = new System.CommandLine.Option("--full") { Description = "Perform a full dehydration that unmounts, backs up the entire src folder, and re-creates the virtualization root from scratch." }; + cmd.Add(fullOption); + + System.CommandLine.Option internalOption = GVFSVerb.CreateInternalParametersOption(); + cmd.Add(internalOption); + + GVFSVerb.SetActionForVerbWithEnlistment(cmd, enlistmentArg, internalOption, defaultEnlistmentPathToCwd: true, + (verb, result) => + { + verb.Confirmed = result.GetValue(confirmOption); + verb.NoStatus = result.GetValue(noStatusOption); + verb.Folders = result.GetValue(foldersOption) ?? ""; + verb.Full = result.GetValue(fullOption); + }); + + return cmd; + } + /// /// True if another verb (e.g. 'gvfs sparse') has already validated that status is clean /// @@ -289,7 +302,6 @@ private void DehydrateFolders(JsonTracer tracer, GVFSEnlistment enlistment, stri using (modifiedPaths) { - string ioError; foreach (string folder in folders) { string normalizedPath = GVFSDatabase.NormalizePath(folder); diff --git a/GVFS/GVFS/CommandLine/DiagnoseVerb.cs b/GVFS/GVFS/CommandLine/DiagnoseVerb.cs index 1d3a71639..d1a624db1 100644 --- a/GVFS/GVFS/CommandLine/DiagnoseVerb.cs +++ b/GVFS/GVFS/CommandLine/DiagnoseVerb.cs @@ -1,4 +1,3 @@ -using CommandLine; using GVFS.Common; using GVFS.Common.FileSystem; using GVFS.Common.Git; @@ -12,7 +11,6 @@ namespace GVFS.CommandLine { - [Verb(DiagnoseVerb.DiagnoseVerbName, HelpText = "Diagnose issues with a GVFS repo")] public class DiagnoseVerb : GVFSVerb.ForExistingEnlistment { private const string DiagnoseVerbName = "diagnose"; @@ -26,6 +24,21 @@ public DiagnoseVerb() : base(false) this.fileSystem = new PhysicalFileSystem(); } + public static System.CommandLine.Command CreateCommand() + { + System.CommandLine.Command cmd = new System.CommandLine.Command("diagnose", "Diagnose issues with a GVFS repo"); + + System.CommandLine.Argument enlistmentArg = GVFSVerb.CreateEnlistmentPathArgument(); + cmd.Add(enlistmentArg); + + System.CommandLine.Option internalOption = GVFSVerb.CreateInternalParametersOption(); + cmd.Add(internalOption); + + GVFSVerb.SetActionForVerbWithEnlistment(cmd, enlistmentArg, internalOption, defaultEnlistmentPathToCwd: true); + + return cmd; + } + protected override string VerbName { get { return DiagnoseVerbName; } @@ -133,13 +146,6 @@ protected override void Execute(GVFSEnlistment enlistment) this.ServiceName, copySubFolders: true); - // service ui - this.CopyAllFiles( - GVFSPlatform.Instance.GetCommonAppDataRootForGVFS(), - archiveFolderPath, - GVFSConstants.Service.UIName, - copySubFolders: true); - if (GVFSPlatform.Instance.UnderConstruction.SupportsGVFSConfig) { this.CopyFile(GVFSPlatform.Instance.GetSecureDataRootForGVFS(), archiveFolderPath, LocalGVFSConfig.FileName); diff --git a/GVFS/GVFS/CommandLine/GVFSVerb.cs b/GVFS/GVFS/CommandLine/GVFSVerb.cs index c254a92d1..9aa112313 100644 --- a/GVFS/GVFS/CommandLine/GVFSVerb.cs +++ b/GVFS/GVFS/CommandLine/GVFSVerb.cs @@ -1,15 +1,14 @@ -using CommandLine; using GVFS.Common; using GVFS.Common.FileSystem; using GVFS.Common.Git; using GVFS.Common.Http; using GVFS.Common.NamedPipes; using GVFS.Common.Tracing; -using Newtonsoft.Json; using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text.Json; using System.Security; using System.Text; @@ -38,10 +37,6 @@ public GVFSVerb(bool validateOrigin = true) public abstract string EnlistmentRootPathParameter { get; set; } - [Option( - GVFSConstants.VerbParameters.InternalUseOnly, - Required = false, - HelpText = "This parameter is reserved for internal use.")] public string InternalParameters { set @@ -68,7 +63,7 @@ public string InternalParameters this.StartedByService = mountInternal.StartedByService; } - catch (JsonReaderException e) + catch (JsonException e) { this.ReportErrorAndExit("Failed to parse InternalParameters: {0}.\n {1}", value, e); } @@ -495,7 +490,7 @@ protected bool TryDownloadCommit( { if (!gitObjects.TryDownloadCommit(commitId)) { - error = "Could not download commit " + commitId + " from: " + Uri.EscapeUriString(objectRequestor.CacheServer.ObjectsEndpointUrl); + error = "Could not download commit " + commitId + " from: " + Uri.EscapeDataString(objectRequestor.CacheServer.ObjectsEndpointUrl); return false; } } @@ -778,7 +773,7 @@ private bool TryValidateGVFSVersion(GVFSEnlistment enlistment, ITracer tracer, S errorMessage = "WARNING: Unable to validate your GVFS version" + Environment.NewLine; if (config == null) { - errorMessage += "Could not query valid GVFS versions from: " + Uri.EscapeUriString(enlistment.RepoUrl); + errorMessage += "Could not query valid GVFS versions from: " + Uri.EscapeDataString(enlistment.RepoUrl); } else { @@ -817,18 +812,97 @@ private bool TryValidateGVFSVersion(GVFSEnlistment enlistment, ITracer tracer, S return false; } + internal static System.CommandLine.Option CreateInternalParametersOption() + { + return new System.CommandLine.Option("--internal_use_only") { Description = "This parameter is reserved for internal use." }; + } + + internal static System.CommandLine.Argument CreateEnlistmentPathArgument(bool required = false) + { + System.CommandLine.Argument arg = new System.CommandLine.Argument("enlistment-root-path"); + arg.Description = "Full or relative path to the GVFS enlistment root"; + arg.Arity = required ? System.CommandLine.ArgumentArity.ExactlyOne : System.CommandLine.ArgumentArity.ZeroOrOne; + if (!required) + { + arg.DefaultValueFactory = (_) => ""; + } + + return arg; + } + + internal static void ApplyInternalParameters(GVFSVerb verb, System.CommandLine.ParseResult result, System.CommandLine.Option internalOption) + { + string internalParams = result.GetValue(internalOption); + if (!string.IsNullOrEmpty(internalParams)) + { + verb.InternalParameters = internalParams; + } + } + + internal static void SetActionForVerbWithEnlistment( + System.CommandLine.Command cmd, + System.CommandLine.Argument enlistmentArg, + System.CommandLine.Option internalOption, + bool defaultEnlistmentPathToCwd, + Action setVerbProperties = null) where T : GVFSVerb, new() + { + cmd.SetAction((System.CommandLine.ParseResult result) => + { + T verb = new T(); + verb.EnlistmentRootPathParameter = result.GetValue(enlistmentArg) ?? ""; + if (verb.EnlistmentRootPathParameter.StartsWith("-")) + { + Console.Error.WriteLine($"Unrecognized option '{verb.EnlistmentRootPathParameter}'"); + Environment.Exit((int)ReturnCode.ParsingError); + } + + if (defaultEnlistmentPathToCwd && string.IsNullOrEmpty(verb.EnlistmentRootPathParameter)) + { + verb.EnlistmentRootPathParameter = Environment.CurrentDirectory; + } + + setVerbProperties?.Invoke(verb, result); + ApplyInternalParameters(verb, result, internalOption); + try + { + verb.Execute(); + } + catch (VerbAbortedException) + { + } + + Environment.Exit((int)verb.ReturnCode); + }); + } + + internal static void SetActionForNoEnlistment( + System.CommandLine.Command cmd, + System.CommandLine.Option internalOption, + Action setVerbProperties = null) where T : ForNoEnlistment, new() + { + cmd.SetAction((System.CommandLine.ParseResult result) => + { + T verb = new T(); + setVerbProperties?.Invoke(verb, result); + ApplyInternalParameters(verb, result, internalOption); + try + { + verb.Execute(); + } + catch (VerbAbortedException) + { + } + + Environment.Exit((int)verb.ReturnCode); + }); + } + public abstract class ForExistingEnlistment : GVFSVerb { public ForExistingEnlistment(bool validateOrigin = true) : base(validateOrigin) { } - [Value( - 0, - Required = false, - Default = "", - MetaName = "Enlistment Root Path", - HelpText = "Full or relative path to the GVFS enlistment root")] public override string EnlistmentRootPathParameter { get; set; } public sealed override void Execute() diff --git a/GVFS/GVFS/CommandLine/HealthVerb.cs b/GVFS/GVFS/CommandLine/HealthVerb.cs index 7f4a42f8e..9f9ed2109 100644 --- a/GVFS/GVFS/CommandLine/HealthVerb.cs +++ b/GVFS/GVFS/CommandLine/HealthVerb.cs @@ -1,4 +1,3 @@ -using CommandLine; using GVFS.Common; using GVFS.Common.FileSystem; using GVFS.Common.NamedPipes; @@ -11,32 +10,51 @@ namespace GVFS.CommandLine { - [Verb(HealthVerb.HealthVerbName, HelpText = "EXPERIMENTAL FEATURE - Measure the health of the repository")] public class HealthVerb : GVFSVerb.ForExistingEnlistment { private const string HealthVerbName = "health"; private const decimal MaximumHealthyHydration = 0.5m; - [Option( - 'n', - Required = false, - HelpText = "Only display the most hydrated directories in the output")] public int DirectoryDisplayCount { get; set; } = 5; - [Option( - 'd', - "directory", - Required = false, - HelpText = "Get the health of a specific directory (default is the current working directory")] public string Directory { get; set; } - [Option( - 's', - "status", - Required = false, - HelpText = "Display only the hydration % of the repository, similar to 'git status' in a repository with sparse-checkout")] public bool StatusOnly { get; set; } + public static System.CommandLine.Command CreateCommand() + { + System.CommandLine.Command cmd = new System.CommandLine.Command("health", "EXPERIMENTAL FEATURE - Measure the health of the repository"); + + System.CommandLine.Argument enlistmentArg = GVFSVerb.CreateEnlistmentPathArgument(); + cmd.Add(enlistmentArg); + + System.CommandLine.Option displayCountOption = new System.CommandLine.Option("-n") + { + Description = "Only display the most hydrated directories in the output", + DefaultValueFactory = (_) => 5, + }; + cmd.Add(displayCountOption); + + System.CommandLine.Option directoryOption = new System.CommandLine.Option("--directory", new[] { "-d" }) { Description = "Get the health of a specific directory (default is the current working directory)" }; + cmd.Add(directoryOption); + + System.CommandLine.Option statusOption = new System.CommandLine.Option("--status", new[] { "-s" }) { Description = "Display only the hydration % of the repository, similar to 'git status' in a repository with sparse-checkout" }; + cmd.Add(statusOption); + + System.CommandLine.Option internalOption = GVFSVerb.CreateInternalParametersOption(); + cmd.Add(internalOption); + + GVFSVerb.SetActionForVerbWithEnlistment(cmd, enlistmentArg, internalOption, defaultEnlistmentPathToCwd: true, + (verb, result) => + { + verb.DirectoryDisplayCount = result.GetValue(displayCountOption); + verb.Directory = result.GetValue(directoryOption); + verb.StatusOnly = result.GetValue(statusOption); + }); + + return cmd; + } + protected override string VerbName => HealthVerbName; internal PhysicalFileSystem FileSystem { get; set; } = new PhysicalFileSystem(); diff --git a/GVFS/GVFS/CommandLine/LogVerb.cs b/GVFS/GVFS/CommandLine/LogVerb.cs index 416a91c6f..a736e3bde 100644 --- a/GVFS/GVFS/CommandLine/LogVerb.cs +++ b/GVFS/GVFS/CommandLine/LogVerb.cs @@ -1,31 +1,42 @@ -using CommandLine; using GVFS.Common; +using System; using System.Collections.Generic; using System.IO; using System.Linq; namespace GVFS.CommandLine { - [Verb(LogVerb.LogVerbName, HelpText = "Show the most recent GVFS log files")] public class LogVerb : GVFSVerb { private const string LogVerbName = "log"; private static readonly int LogNameConsoleOutputFormatWidth = GetMaxLogNameLength(); - [Value( - 0, - Required = false, - Default = "", - MetaName = "Enlistment Root Path", - HelpText = "Full or relative path to the GVFS enlistment root")] public override string EnlistmentRootPathParameter { get; set; } - [Option( - "type", - Default = null, - HelpText = "The type of log file to display on the console")] public string LogType { get; set; } + public static System.CommandLine.Command CreateCommand() + { + System.CommandLine.Command cmd = new System.CommandLine.Command("log", "Show the most recent GVFS log files"); + + System.CommandLine.Argument enlistmentArg = GVFSVerb.CreateEnlistmentPathArgument(); + cmd.Add(enlistmentArg); + + System.CommandLine.Option logTypeOption = new System.CommandLine.Option("--type") { Description = "The type of log file to display on the console" }; + cmd.Add(logTypeOption); + + System.CommandLine.Option internalOption = GVFSVerb.CreateInternalParametersOption(); + cmd.Add(internalOption); + + GVFSVerb.SetActionForVerbWithEnlistment(cmd, enlistmentArg, internalOption, defaultEnlistmentPathToCwd: true, + (verb, result) => + { + verb.LogType = result.GetValue(logTypeOption); + }); + + return cmd; + } + protected override string VerbName { get { return LogVerbName; } diff --git a/GVFS/GVFS/CommandLine/MountVerb.cs b/GVFS/GVFS/CommandLine/MountVerb.cs index 2fa730a8e..8ee8a51ac 100644 --- a/GVFS/GVFS/CommandLine/MountVerb.cs +++ b/GVFS/GVFS/CommandLine/MountVerb.cs @@ -1,4 +1,3 @@ -using CommandLine; using GVFS.Common; using GVFS.Common.Http; using GVFS.Common.NamedPipes; @@ -10,27 +9,48 @@ namespace GVFS.CommandLine { - [Verb(MountVerb.MountVerbName, HelpText = "Mount a GVFS virtual repo")] public class MountVerb : GVFSVerb.ForExistingEnlistment { private const string MountVerbName = "mount"; - [Option( - 'v', - GVFSConstants.VerbParameters.Mount.Verbosity, - Default = GVFSConstants.VerbParameters.Mount.DefaultVerbosity, - Required = false, - HelpText = "Sets the verbosity of console logging. Accepts: Verbose, Informational, Warning, Error")] public string Verbosity { get; set; } - [Option( - 'k', - GVFSConstants.VerbParameters.Mount.Keywords, - Default = GVFSConstants.VerbParameters.Mount.DefaultKeywords, - Required = false, - HelpText = "A CSV list of logging filter keywords. Accepts: Any, Network")] public string KeywordsCsv { get; set; } + public static System.CommandLine.Command CreateCommand() + { + System.CommandLine.Command cmd = new System.CommandLine.Command("mount", "Mount a GVFS virtual repo"); + + System.CommandLine.Argument enlistmentArg = GVFSVerb.CreateEnlistmentPathArgument(); + cmd.Add(enlistmentArg); + + System.CommandLine.Option verbosityOption = new System.CommandLine.Option("--verbosity", new[] { "-v" }) + { + Description = "Sets the verbosity of console logging. Accepts: Verbose, Informational, Warning, Error", + DefaultValueFactory = (_) => GVFSConstants.VerbParameters.Mount.DefaultVerbosity, + }; + cmd.Add(verbosityOption); + + System.CommandLine.Option keywordsOption = new System.CommandLine.Option("--keywords", new[] { "-k" }) + { + Description = "A CSV list of logging filter keywords. Accepts: Any, Network", + DefaultValueFactory = (_) => GVFSConstants.VerbParameters.Mount.DefaultKeywords, + }; + cmd.Add(keywordsOption); + + System.CommandLine.Option internalOption = GVFSVerb.CreateInternalParametersOption(); + cmd.Add(internalOption); + + GVFSVerb.SetActionForVerbWithEnlistment(cmd, enlistmentArg, internalOption, defaultEnlistmentPathToCwd: true, + (verb, result) => + { + verb.Verbosity = result.GetValue(verbosityOption) ?? ""; + verb.KeywordsCsv = result.GetValue(keywordsOption) ?? ""; + }); + + return cmd; + } + public bool SkipMountedCheck { get; set; } public bool SkipVersionCheck { get; set; } public bool SkipInstallHooks { get; set; } diff --git a/GVFS/GVFS/CommandLine/PrefetchVerb.cs b/GVFS/GVFS/CommandLine/PrefetchVerb.cs index 1dd31b3b1..1d3d555b4 100644 --- a/GVFS/GVFS/CommandLine/PrefetchVerb.cs +++ b/GVFS/GVFS/CommandLine/PrefetchVerb.cs @@ -1,4 +1,3 @@ -using CommandLine; using GVFS.Common; using GVFS.Common.FileSystem; using GVFS.Common.Git; @@ -12,7 +11,6 @@ namespace GVFS.CommandLine { - [Verb(PrefetchVerb.PrefetchVerbName, HelpText = "Prefetch remote objects for the current head")] public class PrefetchVerb : GVFSVerb.ForExistingEnlistment { private const string PrefetchVerbName = "prefetch"; @@ -27,70 +25,94 @@ public class PrefetchVerb : GVFSVerb.ForExistingEnlistment private static readonly int DownloadThreadCount = Environment.ProcessorCount; private static readonly int IndexThreadCount = Environment.ProcessorCount; - [Option( - "files", - Required = false, - Default = "", - HelpText = "A semicolon-delimited list of files to fetch. Simple prefix wildcards, e.g. *.txt, are supported.")] public string Files { get; set; } - [Option( - "folders", - Required = false, - Default = "", - HelpText = "A semicolon-delimited list of folders to fetch. Wildcards are not supported.")] public string Folders { get; set; } - [Option( - "folders-list", - Required = false, - Default = "", - HelpText = "A file containing line-delimited list of folders to fetch. Wildcards are not supported.")] public string FoldersListFile { get; set; } - [Option( - "stdin-files-list", - Required = false, - Default = false, - HelpText = "Specify this flag to load file list from stdin. Same format as when loading from file.")] public bool FilesFromStdIn { get; set; } - [Option( - "stdin-folders-list", - Required = false, - Default = false, - HelpText = "Specify this flag to load folder list from stdin. Same format as when loading from file.")] public bool FoldersFromStdIn { get; set; } - [Option( - "files-list", - Required = false, - Default = "", - HelpText = "A file containing line-delimited list of files to fetch. Wildcards are supported.")] public string FilesListFile { get; set; } - [Option( - "hydrate", - Required = false, - Default = false, - HelpText = "Specify this flag to also hydrate files in the working directory.")] public bool HydrateFiles { get; set; } - [Option( - 'c', - "commits", - Required = false, - Default = false, - HelpText = "Fetch the latest set of commit and tree packs. This option cannot be used with any of the file- or folder-related options.")] public bool Commits { get; set; } - [Option( - "verbose", - Required = false, - Default = false, - HelpText = "Show all outputs on the console in addition to writing them to a log file.")] public bool Verbose { get; set; } + public static System.CommandLine.Command CreateCommand() + { + System.CommandLine.Command cmd = new System.CommandLine.Command("prefetch", "Prefetch remote objects for the current head"); + + System.CommandLine.Argument enlistmentArg = GVFSVerb.CreateEnlistmentPathArgument(); + cmd.Add(enlistmentArg); + + System.CommandLine.Option filesOption = new System.CommandLine.Option("--files") + { + Description = "A semicolon-delimited list of files to fetch. Simple prefix wildcards, e.g. *.txt, are supported.", + DefaultValueFactory = (_) => "", + }; + cmd.Add(filesOption); + + System.CommandLine.Option foldersOption = new System.CommandLine.Option("--folders") + { + Description = "A semicolon-delimited list of folders to fetch. Wildcards are not supported.", + DefaultValueFactory = (_) => "", + }; + cmd.Add(foldersOption); + + System.CommandLine.Option foldersListOption = new System.CommandLine.Option("--folders-list") + { + Description = "A file containing line-delimited list of folders to fetch. Wildcards are not supported.", + DefaultValueFactory = (_) => "", + }; + cmd.Add(foldersListOption); + + System.CommandLine.Option stdinFilesOption = new System.CommandLine.Option("--stdin-files-list") { Description = "Specify this flag to load file list from stdin. Same format as when loading from file." }; + cmd.Add(stdinFilesOption); + + System.CommandLine.Option stdinFoldersOption = new System.CommandLine.Option("--stdin-folders-list") { Description = "Specify this flag to load folder list from stdin. Same format as when loading from file." }; + cmd.Add(stdinFoldersOption); + + System.CommandLine.Option filesListOption = new System.CommandLine.Option("--files-list") + { + Description = "A file containing line-delimited list of files to fetch. Wildcards are supported.", + DefaultValueFactory = (_) => "", + }; + cmd.Add(filesListOption); + + System.CommandLine.Option hydrateOption = new System.CommandLine.Option("--hydrate") { Description = "Specify this flag to also hydrate files in the working directory." }; + cmd.Add(hydrateOption); + + System.CommandLine.Option commitsOption = new System.CommandLine.Option("--commits", new[] { "-c" }) { Description = "Fetch the latest set of commit and tree packs. This option cannot be used with any of the file- or folder-related options." }; + cmd.Add(commitsOption); + + System.CommandLine.Option verboseOption = new System.CommandLine.Option("--verbose") { Description = "Show all outputs on the console in addition to writing them to a log file." }; + cmd.Add(verboseOption); + + System.CommandLine.Option internalOption = GVFSVerb.CreateInternalParametersOption(); + cmd.Add(internalOption); + + GVFSVerb.SetActionForVerbWithEnlistment(cmd, enlistmentArg, internalOption, defaultEnlistmentPathToCwd: true, + (verb, result) => + { + verb.Files = result.GetValue(filesOption) ?? ""; + verb.Folders = result.GetValue(foldersOption) ?? ""; + verb.FoldersListFile = result.GetValue(foldersListOption) ?? ""; + verb.FilesFromStdIn = result.GetValue(stdinFilesOption); + verb.FoldersFromStdIn = result.GetValue(stdinFoldersOption); + verb.FilesListFile = result.GetValue(filesListOption) ?? ""; + verb.HydrateFiles = result.GetValue(hydrateOption); + verb.Commits = result.GetValue(commitsOption); + verb.Verbose = result.GetValue(verboseOption); + }); + + return cmd; + } + public bool SkipVersionCheck { get; set; } public CacheServerInfo ResolvedCacheServer { get; set; } public ServerGVFSConfig ServerGVFSConfig { get; set; } diff --git a/GVFS/GVFS/CommandLine/RepairVerb.cs b/GVFS/GVFS/CommandLine/RepairVerb.cs index df42626c6..731af334f 100644 --- a/GVFS/GVFS/CommandLine/RepairVerb.cs +++ b/GVFS/GVFS/CommandLine/RepairVerb.cs @@ -1,34 +1,44 @@ -using CommandLine; using GVFS.Common; using GVFS.Common.NamedPipes; using GVFS.Common.Tracing; using GVFS.DiskLayoutUpgrades; using GVFS.RepairJobs; +using System; using System.Collections.Generic; using System.IO; namespace GVFS.CommandLine { - [Verb(RepairVerb.RepairVerbName, HelpText = "EXPERIMENTAL FEATURE - Repair issues that prevent a GVFS repo from mounting")] public class RepairVerb : GVFSVerb { private const string RepairVerbName = "repair"; - [Value( - 1, - Required = false, - Default = "", - MetaName = "Enlistment Root Path", - HelpText = "Full or relative path to the GVFS enlistment root")] public override string EnlistmentRootPathParameter { get; set; } - [Option( - "confirm", - Default = false, - Required = false, - HelpText = "Pass in this flag to actually do repair(s). Without it, only validation will be done.")] public bool Confirmed { get; set; } + public static System.CommandLine.Command CreateCommand() + { + System.CommandLine.Command cmd = new System.CommandLine.Command("repair", "EXPERIMENTAL FEATURE - Repair issues that prevent a GVFS repo from mounting"); + + System.CommandLine.Argument enlistmentArg = GVFSVerb.CreateEnlistmentPathArgument(); + cmd.Add(enlistmentArg); + + System.CommandLine.Option confirmOption = new System.CommandLine.Option("--confirm") { Description = "Pass in this flag to actually do repair(s). Without it, only validation will be done." }; + cmd.Add(confirmOption); + + System.CommandLine.Option internalOption = GVFSVerb.CreateInternalParametersOption(); + cmd.Add(internalOption); + + GVFSVerb.SetActionForVerbWithEnlistment(cmd, enlistmentArg, internalOption, defaultEnlistmentPathToCwd: true, + (verb, result) => + { + verb.Confirmed = result.GetValue(confirmOption); + }); + + return cmd; + } + protected override string VerbName { get { return RepairVerb.RepairVerbName; } diff --git a/GVFS/GVFS/CommandLine/ServiceVerb.cs b/GVFS/GVFS/CommandLine/ServiceVerb.cs index bf32b2c3a..842521fa7 100644 --- a/GVFS/GVFS/CommandLine/ServiceVerb.cs +++ b/GVFS/GVFS/CommandLine/ServiceVerb.cs @@ -1,4 +1,3 @@ -using CommandLine; using GVFS.Common; using GVFS.Common.FileSystem; using GVFS.Common.NamedPipes; @@ -9,32 +8,43 @@ namespace GVFS.CommandLine { - [Verb(ServiceVerbName, HelpText = "Runs commands for the GVFS service.")] public class ServiceVerb : GVFSVerb.ForNoEnlistment { private const string ServiceVerbName = "service"; - [Option( - "mount-all", - Default = false, - Required = false, - HelpText = "Mounts all repos")] public bool MountAll { get; set; } - [Option( - "unmount-all", - Default = false, - Required = false, - HelpText = "Unmounts all repos")] public bool UnmountAll { get; set; } - [Option( - "list-mounted", - Default = false, - Required = false, - HelpText = "Prints a list of all mounted repos")] public bool List { get; set; } + public static System.CommandLine.Command CreateCommand() + { + System.CommandLine.Command cmd = new System.CommandLine.Command("service", "Runs commands for the GVFS service."); + + System.CommandLine.Option mountAllOption = new System.CommandLine.Option("--mount-all") { Description = "Mounts all repos" }; + cmd.Add(mountAllOption); + + System.CommandLine.Option unmountAllOption = new System.CommandLine.Option("--unmount-all") { Description = "Unmounts all repos" }; + cmd.Add(unmountAllOption); + + System.CommandLine.Option listMountedOption = new System.CommandLine.Option("--list-mounted") { Description = "Prints a list of all mounted repos" }; + cmd.Add(listMountedOption); + + System.CommandLine.Option internalOption = GVFSVerb.CreateInternalParametersOption(); + cmd.Add(internalOption); + + GVFSVerb.SetActionForNoEnlistment(cmd, internalOption, + (verb, result) => + { + verb.MountAll = result.GetValue(mountAllOption); + verb.UnmountAll = result.GetValue(unmountAllOption); + verb.List = result.GetValue(listMountedOption); + }); + + return cmd; + } + protected override string VerbName { get { return ServiceVerbName; } diff --git a/GVFS/GVFS/CommandLine/SparseVerb.cs b/GVFS/GVFS/CommandLine/SparseVerb.cs index 211d5131e..bf5150c27 100644 --- a/GVFS/GVFS/CommandLine/SparseVerb.cs +++ b/GVFS/GVFS/CommandLine/SparseVerb.cs @@ -1,4 +1,3 @@ -using CommandLine; using GVFS.Common; using GVFS.Common.Database; using GVFS.Common.FileSystem; @@ -15,11 +14,6 @@ namespace GVFS.CommandLine { - [Verb( - SparseVerb.SparseVerbName, - HelpText = @"EXPERIMENTAL: List, add, or remove from the list of folders that are included in VFS for Git's projection. -Folders need to be relative to the repos root directory.") - ] public class SparseVerb : GVFSVerb.ForExistingEnlistment { private const string SparseVerbName = "sparse"; @@ -35,62 +29,82 @@ private enum SetDirectoryTimeResult DirectoryDoesNotExist } - [Option( - 's', - "set", - Required = false, - Default = "", - HelpText = "A semicolon-delimited list of repo root relative folders to use as the sparse set for determining what to project. Wildcards are not supported.")] public string Set { get; set; } - [Option( - 'f', - "file", - Required = false, - Default = "", - HelpText = "Path to a file that will has repo root relative folders to use as the sparse set. One folder per line. Wildcards are not supported.")] public string File { get; set; } - [Option( - 'a', - "add", - Required = false, - Default = "", - HelpText = "A semicolon-delimited list of repo root relative folders to include in the sparse set for determining what to project. Wildcards are not supported.")] public string Add { get; set; } - [Option( - 'r', - "remove", - Required = false, - Default = "", - HelpText = "A semicolon-delimited list of repo root relative folders to remove from the sparse set for determining what to project. Wildcards are not supported.")] public string Remove { get; set; } - [Option( - 'l', - "list", - Required = false, - Default = false, - HelpText = "List of folders in the sparse set for determining what to project.")] public bool List { get; set; } - [Option( - 'p', - PruneOptionName, - Required = false, - Default = false, - HelpText = "Remove any folders that are not in the list of sparse folders.")] public bool Prune { get; set; } - [Option( - 'd', - "disable", - Required = false, - Default = false, - HelpText = "Disable the sparse feature. This will remove all folders in the sparse list and start projecting all folders.")] public bool Disable { get; set; } + public static System.CommandLine.Command CreateCommand() + { + System.CommandLine.Command cmd = new System.CommandLine.Command("sparse", "List, add, or remove from the list of folders included in VFS for Git's projection"); + + System.CommandLine.Argument enlistmentArg = GVFSVerb.CreateEnlistmentPathArgument(); + cmd.Add(enlistmentArg); + + System.CommandLine.Option setOption = new System.CommandLine.Option("--set", new[] { "-s" }) + { + Description = "A semicolon-delimited list of repo root relative folders to use as the sparse set for determining what to project. Wildcards are not supported.", + DefaultValueFactory = (_) => "", + }; + cmd.Add(setOption); + + System.CommandLine.Option fileOption = new System.CommandLine.Option("--file", new[] { "-f" }) + { + Description = "Path to a file that will has repo root relative folders to use as the sparse set. One folder per line. Wildcards are not supported.", + DefaultValueFactory = (_) => "", + }; + cmd.Add(fileOption); + + System.CommandLine.Option addOption = new System.CommandLine.Option("--add", new[] { "-a" }) + { + Description = "A semicolon-delimited list of repo root relative folders to include in the sparse set for determining what to project. Wildcards are not supported.", + DefaultValueFactory = (_) => "", + }; + cmd.Add(addOption); + + System.CommandLine.Option removeOption = new System.CommandLine.Option("--remove", new[] { "-r" }) + { + Description = "A semicolon-delimited list of repo root relative folders to remove from the sparse set for determining what to project. Wildcards are not supported.", + DefaultValueFactory = (_) => "", + }; + cmd.Add(removeOption); + + System.CommandLine.Option listOption = new System.CommandLine.Option("--list", new[] { "-l" }) { Description = "List of folders in the sparse set for determining what to project." }; + cmd.Add(listOption); + + System.CommandLine.Option pruneOption = new System.CommandLine.Option("--prune", new[] { "-p" }) { Description = "Remove any folders that are not in the list of sparse folders." }; + cmd.Add(pruneOption); + + System.CommandLine.Option disableOption = new System.CommandLine.Option("--disable", new[] { "-d" }) { Description = "Disable the sparse feature. This will remove all folders in the sparse list and start projecting all folders." }; + cmd.Add(disableOption); + + System.CommandLine.Option internalOption = GVFSVerb.CreateInternalParametersOption(); + cmd.Add(internalOption); + + GVFSVerb.SetActionForVerbWithEnlistment(cmd, enlistmentArg, internalOption, defaultEnlistmentPathToCwd: true, + (verb, result) => + { + verb.Set = result.GetValue(setOption) ?? ""; + verb.File = result.GetValue(fileOption) ?? ""; + verb.Add = result.GetValue(addOption) ?? ""; + verb.Remove = result.GetValue(removeOption) ?? ""; + verb.List = result.GetValue(listOption); + verb.Prune = result.GetValue(pruneOption); + verb.Disable = result.GetValue(disableOption); + }); + + return cmd; + } + protected override string VerbName => SparseVerbName; internal static string GetNextGitPath(ref int index, string statusOutput) diff --git a/GVFS/GVFS/CommandLine/StatusVerb.cs b/GVFS/GVFS/CommandLine/StatusVerb.cs index 8be1bfb55..ead87842b 100644 --- a/GVFS/GVFS/CommandLine/StatusVerb.cs +++ b/GVFS/GVFS/CommandLine/StatusVerb.cs @@ -1,12 +1,26 @@ -using CommandLine; using GVFS.Common; using GVFS.Common.NamedPipes; +using System; namespace GVFS.CommandLine { - [Verb(StatusVerb.StatusVerbName, HelpText = "Get the status of the GVFS virtual repo")] public class StatusVerb : GVFSVerb.ForExistingEnlistment { + public static System.CommandLine.Command CreateCommand() + { + System.CommandLine.Command cmd = new System.CommandLine.Command("status", "Get the status of the GVFS virtual repo"); + + System.CommandLine.Argument enlistmentArg = GVFSVerb.CreateEnlistmentPathArgument(); + cmd.Add(enlistmentArg); + + System.CommandLine.Option internalOption = GVFSVerb.CreateInternalParametersOption(); + cmd.Add(internalOption); + + GVFSVerb.SetActionForVerbWithEnlistment(cmd, enlistmentArg, internalOption, defaultEnlistmentPathToCwd: true); + + return cmd; + } + private const string StatusVerbName = "status"; protected override string VerbName diff --git a/GVFS/GVFS/CommandLine/UnmountVerb.cs b/GVFS/GVFS/CommandLine/UnmountVerb.cs index eebb4a3b1..4804d9415 100644 --- a/GVFS/GVFS/CommandLine/UnmountVerb.cs +++ b/GVFS/GVFS/CommandLine/UnmountVerb.cs @@ -1,30 +1,40 @@ -using CommandLine; using GVFS.Common; using GVFS.Common.NamedPipes; +using System; using System.Diagnostics; namespace GVFS.CommandLine { - [Verb(UnmountVerb.UnmountVerbName, HelpText = "Unmount a GVFS virtual repo")] public class UnmountVerb : GVFSVerb { private const string UnmountVerbName = "unmount"; - [Value( - 0, - Required = false, - Default = "", - MetaName = "Enlistment Root Path", - HelpText = "Full or relative path to the GVFS enlistment root")] public override string EnlistmentRootPathParameter { get; set; } - [Option( - GVFSConstants.VerbParameters.Unmount.SkipLock, - Default = false, - Required = false, - HelpText = "Force unmount even if the lock is not available.")] public bool SkipLock { get; set; } + public static System.CommandLine.Command CreateCommand() + { + System.CommandLine.Command cmd = new System.CommandLine.Command("unmount", "Unmount a GVFS virtual repo"); + + System.CommandLine.Argument enlistmentArg = GVFSVerb.CreateEnlistmentPathArgument(); + cmd.Add(enlistmentArg); + + System.CommandLine.Option skipLockOption = new System.CommandLine.Option("--" + GVFSConstants.VerbParameters.Unmount.SkipLock) { Description = "Force unmount even if the lock is not available." }; + cmd.Add(skipLockOption); + + System.CommandLine.Option internalOption = GVFSVerb.CreateInternalParametersOption(); + cmd.Add(internalOption); + + GVFSVerb.SetActionForVerbWithEnlistment(cmd, enlistmentArg, internalOption, defaultEnlistmentPathToCwd: true, + (verb, result) => + { + verb.SkipLock = result.GetValue(skipLockOption); + }); + + return cmd; + } + public bool SkipUnregister { get; set; } protected override string VerbName diff --git a/GVFS/GVFS/CommandLine/UpgradeVerb.cs b/GVFS/GVFS/CommandLine/UpgradeVerb.cs index 49d2bd9f9..70f135d96 100644 --- a/GVFS/GVFS/CommandLine/UpgradeVerb.cs +++ b/GVFS/GVFS/CommandLine/UpgradeVerb.cs @@ -1,9 +1,8 @@ -using CommandLine; +using GVFS.Common; using System; namespace GVFS.CommandLine { - [Verb(UpgradeVerbName, HelpText = "Checks for new GVFS release, downloads and installs it when available.")] public class UpgradeVerb : GVFSVerb.ForNoEnlistment { private const string UpgradeVerbName = "upgrade"; @@ -13,27 +12,39 @@ public UpgradeVerb() this.Output = Console.Out; } - [Option( - "confirm", - Default = false, - Required = false, - HelpText = "Pass in this flag to actually install the newest release")] public bool Confirmed { get; set; } - [Option( - "dry-run", - Default = false, - Required = false, - HelpText = "Display progress and errors, but don't install GVFS")] public bool DryRun { get; set; } - [Option( - "no-verify", - Default = false, - Required = false, - HelpText = "Do not verify NuGet packages after downloading them. Some platforms do not support NuGet verification.")] public bool NoVerify { get; set; } + public static System.CommandLine.Command CreateCommand() + { + System.CommandLine.Command cmd = new System.CommandLine.Command("upgrade", "Checks for new GVFS release, downloads and installs it when available."); + + System.CommandLine.Option confirmOption = new System.CommandLine.Option("--confirm") { Description = "Pass in this flag to actually install the newest release" }; + cmd.Add(confirmOption); + + System.CommandLine.Option dryRunOption = new System.CommandLine.Option("--dry-run") { Description = "Display progress and errors, but don't install GVFS" }; + cmd.Add(dryRunOption); + + System.CommandLine.Option noVerifyOption = new System.CommandLine.Option("--no-verify") { Description = "Do not verify NuGet packages after downloading them. Some platforms do not support NuGet verification." }; + cmd.Add(noVerifyOption); + + System.CommandLine.Option internalOption = GVFSVerb.CreateInternalParametersOption(); + cmd.Add(internalOption); + + GVFSVerb.SetActionForNoEnlistment(cmd, internalOption, + (verb, result) => + { + verb.Confirmed = result.GetValue(confirmOption); + verb.DryRun = result.GetValue(dryRunOption); + verb.NoVerify = result.GetValue(noVerifyOption); + }); + + return cmd; + } + protected override string VerbName { get { return UpgradeVerbName; } diff --git a/GVFS/GVFS/GVFS.csproj b/GVFS/GVFS/GVFS.csproj index 892bc1386..dd882915c 100644 --- a/GVFS/GVFS/GVFS.csproj +++ b/GVFS/GVFS/GVFS.csproj @@ -2,21 +2,17 @@ Exe - net471 false - Content - PreserveNewest - Build;DebugSymbolsProjectOutputGroup - + @@ -26,3 +22,4 @@ + diff --git a/GVFS/GVFS/InternalsVisibleTo.cs b/GVFS/GVFS/InternalsVisibleTo.cs index 0ba48d81b..248b151be 100644 --- a/GVFS/GVFS/InternalsVisibleTo.cs +++ b/GVFS/GVFS/InternalsVisibleTo.cs @@ -1,3 +1,4 @@ -using System.Runtime.CompilerServices; +using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("GVFS.UnitTests")] +[assembly: InternalsVisibleTo("GVFS.CommandLine.Tests")] diff --git a/GVFS/GVFS/Program.cs b/GVFS/GVFS/Program.cs index 81d712d52..e8dc0335c 100644 --- a/GVFS/GVFS/Program.cs +++ b/GVFS/GVFS/Program.cs @@ -1,10 +1,8 @@ -using CommandLine; +using System.CommandLine; using GVFS.CommandLine; using GVFS.Common; using GVFS.PlatformLoader; using System; -using System.IO; -using System.Linq; namespace GVFS { @@ -19,88 +17,25 @@ public static void Main(string[] args) Environment.Exit((int)ReturnCode.UnableToRegisterForOfflineIO); } - Type[] verbTypes = new Type[] - { - typeof(CacheServerVerb), - typeof(CacheVerb), - typeof(CloneVerb), - typeof(ConfigVerb), - typeof(DehydrateVerb), - typeof(DiagnoseVerb), - typeof(LogVerb), - typeof(SparseVerb), - typeof(MountVerb), - typeof(PrefetchVerb), - typeof(RepairVerb), - typeof(ServiceVerb), - typeof(HealthVerb), - typeof(StatusVerb), - typeof(UnmountVerb), - typeof(UpgradeVerb), - }; - - int consoleWidth = 80; - - // Running in a headless environment can result in a Console with a - // WindowWidth of 0, which causes issues with CommandLineParser - try - { - if (Console.WindowWidth > 0) - { - consoleWidth = Console.WindowWidth; - } - } - catch (IOException) + // Normalize verb name to lowercase for case-insensitive matching. + // The old CommandLineParser had CaseSensitive = false; System.CommandLine + // is case-sensitive, so we normalize the first non-option argument. + if (args.Length > 0 && !args[0].StartsWith("-")) { + args[0] = args[0].ToLowerInvariant(); } try { - new Parser( - settings => - { - settings.CaseSensitive = false; - settings.EnableDashDash = true; - settings.IgnoreUnknownArguments = false; - settings.HelpWriter = Console.Error; - settings.MaximumDisplayWidth = consoleWidth; - }) - .ParseArguments(args, verbTypes) - .WithNotParsed( - errors => - { - if (errors.Any(error => error is TokenError)) - { - Environment.Exit((int)ReturnCode.ParsingError); - } - }) - .WithParsed( - clone => - { - // We handle the clone verb differently, because clone cares if the enlistment path - // was not specified vs if it was specified to be the current directory - clone.Execute(); - Environment.Exit((int)ReturnCode.Success); - }) - .WithParsed( - verb => - { - verb.Execute(); - Environment.Exit((int)ReturnCode.Success); - }) - .WithParsed( - verb => - { - // For all other verbs, they don't care if the enlistment root is explicitly - // specified or implied to be the current directory - if (string.IsNullOrEmpty(verb.EnlistmentRootPathParameter)) - { - verb.EnlistmentRootPathParameter = Environment.CurrentDirectory; - } + RootCommand rootCommand = BuildRootCommand(); + int exitCode = rootCommand.Parse(args).Invoke(); - verb.Execute(); - Environment.Exit((int)ReturnCode.Success); - }); + // If a verb executed successfully, its SetAction already called Environment.Exit. + // If we reach here, it means parsing failed or help was shown. + if (exitCode != 0) + { + Environment.Exit((int)ReturnCode.ParsingError); + } } catch (GVFSVerb.VerbAbortedException e) { @@ -115,5 +50,88 @@ public static void Main(string[] args) } } } + + internal static RootCommand BuildRootCommand() + { + RootCommand rootCommand = new RootCommand("VFS for Git: Enable Git at Enterprise Scale"); + + // Remove System.CommandLine's built-in --version option and replace + // with our own that uses ProcessHelper.GetCurrentProcessVersion() + // for consistent output with "gvfs version" and AOT compatibility. + foreach (Option opt in rootCommand.Options) + { + if (opt.Name == "--version") + { + rootCommand.Options.Remove(opt); + break; + } + } + + Option versionOption = new Option("--version", "-v") { Description = "Display the GVFS version" }; + rootCommand.Add(versionOption); + rootCommand.SetAction((ParseResult result) => + { + if (result.GetValue(versionOption)) + { + Console.WriteLine("GVFS " + ProcessHelper.GetCurrentProcessVersion()); + } + else + { + // No args — show help + rootCommand.Parse(new[] { "--help" }).Invoke(); + } + }); + + rootCommand.Add(CacheServerVerb.CreateCommand()); + rootCommand.Add(CacheVerb.CreateCommand()); + rootCommand.Add(CloneVerb.CreateCommand()); + rootCommand.Add(ConfigVerb.CreateCommand()); + rootCommand.Add(DehydrateVerb.CreateCommand()); + rootCommand.Add(DiagnoseVerb.CreateCommand()); + rootCommand.Add(HealthVerb.CreateCommand()); + rootCommand.Add(LogVerb.CreateCommand()); + rootCommand.Add(MountVerb.CreateCommand()); + rootCommand.Add(PrefetchVerb.CreateCommand()); + rootCommand.Add(RepairVerb.CreateCommand()); + rootCommand.Add(ServiceVerb.CreateCommand()); + rootCommand.Add(SparseVerb.CreateCommand()); + rootCommand.Add(StatusVerb.CreateCommand()); + rootCommand.Add(UnmountVerb.CreateCommand()); + rootCommand.Add(UpgradeVerb.CreateCommand()); + + Command versionCmd = new Command("version", "Display the GVFS version"); + versionCmd.SetAction((ParseResult result) => + { + Console.WriteLine("GVFS " + ProcessHelper.GetCurrentProcessVersion()); + }); + rootCommand.Add(versionCmd); + + // Explicit "help" subcommand for backward compatibility. + // System.CommandLine handles --help/-h/-? automatically, but the old + // CommandLineParser also accepted "gvfs help" as a bare subcommand. + Command helpCmd = new Command("help", "Display help information"); + Argument helpSubcommandArg = new Argument("subcommand") + { + Description = "The subcommand to get help for", + Arity = ArgumentArity.ZeroOrOne, + }; + helpSubcommandArg.DefaultValueFactory = (_) => ""; + helpCmd.Add(helpSubcommandArg); + helpCmd.SetAction((ParseResult result) => + { + string subcommand = result.GetValue(helpSubcommandArg) ?? ""; + if (!string.IsNullOrEmpty(subcommand)) + { + rootCommand.Parse(new[] { subcommand, "--help" }).Invoke(); + } + else + { + rootCommand.Parse(new[] { "--help" }).Invoke(); + } + }); + rootCommand.Add(helpCmd); + + return rootCommand; + } } } diff --git a/global.json b/global.json index f7ef5e1e9..063181e42 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,10 @@ { + "sdk": { + "version": "10.0.203", + "rollForward": "disable" + }, "msbuild-sdks": { - "Microsoft.Build.Traversal": "2.0.19", - "Microsoft.Build.NoTargets": "1.0.85" + "Microsoft.Build.Traversal": "4.1.0", + "Microsoft.Build.NoTargets": "3.7.0" } } diff --git a/scripts/Build.bat b/scripts/Build.bat index e51c8ebe8..32ab6f565 100644 --- a/scripts/Build.bat +++ b/scripts/Build.bat @@ -21,61 +21,91 @@ IF "%~3"=="" ( SET VERBOSITY=%3 ) -REM If we have MSBuild on the PATH then go straight to the build phase -FOR /F "tokens=* USEBACKQ" %%F IN (`where msbuild.exe`) DO ( +REM .NET 10 SDK ships MSBuild 18.x; VS 2022 ships MSBuild 17.x. +REM Managed (csproj) projects require MSBuild 18.x via "dotnet build". +REM Native C++ (vcxproj) projects require VS MSBuild with VC++ targets. + +ECHO ^********************** +ECHO ^* Restoring Packages * +ECHO ^********************** +dotnet restore "%VFS_SRCDIR%\GVFS.sln" ^ + /v:%VERBOSITY% ^ + /p:Configuration=%CONFIGURATION% || GOTO ERROR + +ECHO ^************************** +ECHO ^* Building C++ Projects * +ECHO ^************************** +REM Locate VS MSBuild for native C++ projects +SET MSBUILD_EXEC= +FOR /F "tokens=* USEBACKQ" %%F IN (`where msbuild.exe 2^>nul`) DO ( SET MSBUILD_EXEC=%%F ECHO INFO: Found msbuild.exe at '%%F' - GOTO :BUILD + GOTO :FOUND_MSBUILD ) :LOCATE_MSBUILD -REM Locate MSBuild via the vswhere tool -FOR /F "tokens=* USEBACKQ" %%F IN (`where nuget.exe`) DO ( - SET NUGET_EXEC=%%F - ECHO INFO: Found nuget.exe at '%%F' +SET VSWHERE_EXEC="%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe" +IF EXIST %VSWHERE_EXEC% ( + FOR /F "tokens=* USEBACKQ" %%F IN (`%VSWHERE_EXEC% -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -find MSBuild\**\Bin\amd64\MSBuild.exe`) DO ( + SET MSBUILD_EXEC=%%F + ECHO INFO: Found msbuild.exe at '%%F' + ) ) -REM NuGet is required to be on the PATH to install vswhere -IF NOT EXIST "%NUGET_EXEC%" ( - ECHO ERROR: Could not find nuget.exe on the PATH - EXIT /B 10 +:FOUND_MSBUILD +IF DEFINED MSBUILD_EXEC ( + FOR %%P IN ( + "%VFS_SRCDIR%\GVFS\GitHooksLoader\GitHooksLoader.vcxproj" + "%VFS_SRCDIR%\GVFS\GVFS.NativeTests\GVFS.NativeTests.vcxproj" + "%VFS_SRCDIR%\GVFS\GVFS.PostIndexChangedHook\GVFS.PostIndexChangedHook.vcxproj" + "%VFS_SRCDIR%\GVFS\GVFS.ReadObjectHook\GVFS.ReadObjectHook.vcxproj" + "%VFS_SRCDIR%\GVFS\GVFS.VirtualFileSystemHook\GVFS.VirtualFileSystemHook.vcxproj" + ) DO ( + ECHO Building %%~nP... + "%MSBUILD_EXEC%" %%P ^ + /t:Build ^ + /v:%VERBOSITY% ^ + /p:Configuration=%CONFIGURATION% ^ + /p:Platform=x64 ^ + /p:SolutionDir="%VFS_SRCDIR%\\" || GOTO ERROR + ) +) ELSE ( + ECHO WARNING: Could not find VS MSBuild. Native C++ projects will not be built. + ECHO Install Visual Studio with the C++ workload to build native projects. ) -REM Acquire vswhere to find VS installations reliably -SET VSWHERE_VER=2.6.7 -"%NUGET_EXEC%" install vswhere -Version %VSWHERE_VER% -OutputDirectory %VFS_PACKAGESDIR% || exit /b 1 -SET VSWHERE_EXEC="%VFS_PACKAGESDIR%\vswhere.%VSWHERE_VER%\tools\vswhere.exe" - -REM Use vswhere to find the latest VS installation with the MSBuild component -REM See https://github.com/Microsoft/vswhere/wiki/Find-MSBuild -FOR /F "tokens=* USEBACKQ" %%F IN (`%VSWHERE_EXEC% -all -prerelease -latest -products * -requires Microsoft.Component.MSBuild -find MSBuild\**\Bin\amd64\MSBuild.exe`) DO ( - SET MSBUILD_EXEC=%%F - ECHO INFO: Found msbuild.exe at '%%F' +ECHO ^***************************** +ECHO ^* Building Managed Projects * +ECHO ^***************************** +REM Self-contained deployment requires "dotnet publish" (not "dotnet build") +REM to produce complete output with runtime and correct version resources. +FOR %%P IN ( + "%VFS_SRCDIR%\GVFS\GVFS\GVFS.csproj" + "%VFS_SRCDIR%\GVFS\GVFS.Mount\GVFS.Mount.csproj" + "%VFS_SRCDIR%\GVFS\GVFS.Hooks\GVFS.Hooks.csproj" + "%VFS_SRCDIR%\GVFS\GVFS.Service\GVFS.Service.csproj" + "%VFS_SRCDIR%\GVFS\FastFetch\FastFetch.csproj" + "%VFS_SRCDIR%\GVFS\GVFS.UnitTests\GVFS.UnitTests.csproj" + "%VFS_SRCDIR%\GVFS\GVFS.FunctionalTests\GVFS.FunctionalTests.csproj" + "%VFS_SRCDIR%\GVFS\GVFS.PerfProfiling\GVFS.PerfProfiling.csproj" +) DO ( + ECHO Publishing %%~nP... + dotnet publish %%P --no-restore -v:%VERBOSITY% -c %CONFIGURATION% || GOTO ERROR ) -:BUILD -IF NOT DEFINED MSBUILD_EXEC ( - ECHO ERROR: Could not locate a Visual Studio installation with required components. - ECHO Refer to Readme.md for a list of the required Visual Studio components. - EXIT /B 10 +ECHO ^******************************* +ECHO ^* Building Packaging Projects * +ECHO ^******************************* +REM Payload and Installers no longer reference vcxproj (native projects are +REM built separately above). Build ordering is handled by Build.bat. +FOR %%P IN ( + "%VFS_SRCDIR%\GVFS\GVFS.Payload\GVFS.Payload.csproj" + "%VFS_SRCDIR%\GVFS\GVFS.Installers\GVFS.Installers.csproj" +) DO ( + ECHO Publishing %%~nP... + dotnet publish %%P --no-restore -v:%VERBOSITY% -c %CONFIGURATION% || GOTO ERROR ) -ECHO ^********************** -ECHO ^* Restoring Packages * -ECHO ^********************** -"%MSBUILD_EXEC%" "%VFS_SRCDIR%\GVFS.sln" ^ - /t:Restore ^ - /v:%VERBOSITY% ^ - /p:Configuration=%CONFIGURATION% || GOTO ERROR - -ECHO ^********************* -ECHO ^* Building Solution * -ECHO ^********************* -"%MSBUILD_EXEC%" "%VFS_SRCDIR%\GVFS.sln" ^ - /t:Build ^ - /v:%VERBOSITY% ^ - /p:Configuration=%CONFIGURATION% || GOTO ERROR - GOTO :EOF :USAGE diff --git a/scripts/CreateBuildArtifacts.bat b/scripts/CreateBuildArtifacts.bat index 797ed0bf9..d27f5a64c 100644 --- a/scripts/CreateBuildArtifacts.bat +++ b/scripts/CreateBuildArtifacts.bat @@ -33,7 +33,7 @@ ECHO ^* Collecting GVFS.Installers * ECHO ^****************************** mkdir %OUTROOT%\GVFS.Installers xcopy /S /Y ^ - %VFS_OUTDIR%\GVFS.Installers\bin\%CONFIGURATION%\win-x64 ^ + %VFS_OUTDIR%\GVFS.Installers\bin\%CONFIGURATION%\win-x64\* ^ %OUTROOT%\GVFS.Installers\ || GOTO ERROR ECHO ^************************ @@ -42,7 +42,7 @@ ECHO ^************************ ECHO Collecting FastFetch... mkdir %OUTROOT%\FastFetch xcopy /S /Y ^ - %VFS_OUTDIR%\FastFetch\bin\%CONFIGURATION%\net471\win-x64 ^ + %VFS_OUTDIR%\FastFetch\bin\%CONFIGURATION%\net10.0-windows10.0.17763.0\win-x64\publish\* ^ %OUTROOT%\FastFetch\ || GOTO ERROR ECHO ^*********************************** @@ -50,7 +50,7 @@ ECHO ^* Collecting GVFS.FunctionalTests * ECHO ^*********************************** mkdir %OUTROOT%\GVFS.FunctionalTests xcopy /S /Y ^ - %VFS_OUTDIR%\GVFS.FunctionalTests\bin\%CONFIGURATION%\net471\win-x64 ^ + %VFS_OUTDIR%\GVFS.FunctionalTests\bin\%CONFIGURATION%\net10.0-windows10.0.17763.0\win-x64\publish\* ^ %OUTROOT%\GVFS.FunctionalTests\ || GOTO ERROR GOTO :EOF diff --git a/scripts/RunFunctionalTests-Dev.ps1 b/scripts/RunFunctionalTests-Dev.ps1 index 3fe4ba540..048afa6b2 100644 --- a/scripts/RunFunctionalTests-Dev.ps1 +++ b/scripts/RunFunctionalTests-Dev.ps1 @@ -59,7 +59,7 @@ $env:GVFS_COMMON_APPDATA_ROOT = Join-Path $env:GVFS_TEST_DATA "AppData" $env:GVFS_SECURE_DATA_ROOT = Join-Path $env:GVFS_TEST_DATA "ProgramData" # Put build output gvfs.exe on PATH -$payloadDir = Join-Path $outDir "GVFS.Payload\bin\$Configuration\win-x64" +$payloadDir = Join-Path $outDir "GVFS.Payload\bin\$Configuration\net10.0-windows10.0.17763.0\win-x64\publish" $env:PATH = "$payloadDir;C:\Program Files\Git\cmd;$env:PATH" Write-Host "============================================" @@ -88,7 +88,7 @@ Write-Host "git location: $($gitPath.Source)" Write-Host "" # Build test exe path -$testExe = Join-Path $outDir "GVFS.FunctionalTests\bin\$Configuration\net471\win-x64\GVFS.FunctionalTests.exe" +$testExe = Join-Path $outDir "GVFS.FunctionalTests\bin\$Configuration\net10.0-windows10.0.17763.0\win-x64\publish\GVFS.FunctionalTests.exe" if (-not (Test-Path $testExe)) { Write-Error "Test executable not found: $testExe`nRun Build.bat first." exit 1 diff --git a/scripts/RunFunctionalTests.bat b/scripts/RunFunctionalTests.bat index ef86a74f8..6f58db61a 100644 --- a/scripts/RunFunctionalTests.bat +++ b/scripts/RunFunctionalTests.bat @@ -27,7 +27,7 @@ IF NOT %ERRORLEVEL% == 0 ( ECHO error: unable to locate Git on the PATH (has it been installed?) ) -%VFS_OUTDIR%\GVFS.FunctionalTests\bin\%CONFIGURATION%\net471\win-x64\GVFS.FunctionalTests.exe /result:TestResult.xml %2 %3 %4 %5 +%VFS_OUTDIR%\GVFS.FunctionalTests\bin\%CONFIGURATION%\net10.0-windows10.0.17763.0\win-x64\publish\GVFS.FunctionalTests.exe /result:TestResult.xml %2 %3 %4 %5 SET error=%ERRORLEVEL% CALL %VFS_SCRIPTSDIR%\StopAllServices.bat diff --git a/scripts/RunUnitTests.bat b/scripts/RunUnitTests.bat index 3424825ca..669b0b17a 100644 --- a/scripts/RunUnitTests.bat +++ b/scripts/RunUnitTests.bat @@ -5,6 +5,6 @@ IF "%1"=="" (SET "CONFIGURATION=Debug") ELSE (SET "CONFIGURATION=%1") SET RESULT=0 -%VFS_OUTDIR%\GVFS.UnitTests\bin\%CONFIGURATION%\net471\win-x64\GVFS.UnitTests.exe || SET RESULT=1 +%VFS_OUTDIR%\GVFS.UnitTests\bin\%CONFIGURATION%\net10.0-windows10.0.17763.0\win-x64\publish\GVFS.UnitTests.exe || SET RESULT=1 EXIT /b %RESULT% diff --git a/scripts/publish-aot.ps1 b/scripts/publish-aot.ps1 new file mode 100644 index 000000000..54bb1bb04 --- /dev/null +++ b/scripts/publish-aot.ps1 @@ -0,0 +1,244 @@ +<# +.SYNOPSIS + Publish VFSForGit .NET 10 NativeAOT binaries and create installer layout. + +.DESCRIPTION + Builds all GVFS projects as self-contained NativeAOT executables, + then assembles them into a flat layout directory suitable for the Inno Setup + installer (Setup.iss). + + Supports building for win-x64, win-arm64, or both architectures. + + The NativeAOT layout is dramatically simpler than the .NET Framework layout: + just 9 self-contained .exe files instead of ~150 DLLs + runtimes. + +.PARAMETER Configuration + Build configuration: Debug or Release (default: Release) + +.PARAMETER Runtime + Target runtime: win-x64, win-arm64, or both (default: both) + +.PARAMETER OutputDir + Layout output directory. When building both architectures, -x64 and -arm64 + suffixes are appended. (default: $RepoRoot\..\out\gvfs-aot-layout) + +.PARAMETER SkipBuild + Skip the dotnet publish step (use existing build output) + +.PARAMETER BuildInstaller + Also build the Inno Setup installer after creating the layout +#> +param( + [ValidateSet("Debug", "Release")] + [string]$Configuration = "Release", + [ValidateSet("win-x64", "win-arm64", "both")] + [string]$Runtime = "both", + [string]$OutputDir = "", + [switch]$SkipBuild, + [switch]$BuildInstaller +) + +$ErrorActionPreference = "Stop" + +$RepoRoot = Split-Path -Parent $PSScriptRoot +$GVFSRoot = Join-Path $RepoRoot "GVFS" +$OutBase = if ($OutputDir) { $OutputDir } else { Join-Path (Split-Path $RepoRoot) "out\gvfs-aot-layout" } + +# Determine which runtimes to build +$Runtimes = if ($Runtime -eq "both") { @("win-x64", "win-arm64") } else { @($Runtime) } + +Write-Host "=== VFSForGit NativeAOT Publish ===" -ForegroundColor Cyan +Write-Host "Configuration: $Configuration" +Write-Host "Runtimes: $($Runtimes -join ', ')" +Write-Host "" + +# ───────────────────────────────────────────────────────────────────────────── +# Project definitions +# ───────────────────────────────────────────────────────────────────────────── +$ManagedProjects = @( + @{ Name = "GVFS"; Dir = "GVFS"; Exe = "GVFS.exe" }, + @{ Name = "GVFS.Mount"; Dir = "GVFS.Mount"; Exe = "GVFS.Mount.exe" }, + @{ Name = "GVFS.Hooks"; Dir = "GVFS.Hooks"; Exe = "GVFS.Hooks.exe" }, + @{ Name = "GVFS.Service"; Dir = "GVFS.Service"; Exe = "GVFS.Service.exe" }, + @{ Name = "GVFS.Service.UI"; Dir = "GVFS.Service.UI"; Exe = "GVFS.Service.UI.exe" } +) + +# Native C++ projects (built with MSBuild, not dotnet publish) +$NativeProjects = @( + @{ Name = "GitHooksLoader"; Dir = "GitHooksLoader"; Exe = "GitHooksLoader.exe" }, + @{ Name = "GVFS.ReadObjectHook"; Dir = "GVFS.ReadObjectHook"; Exe = "GVFS.ReadObjectHook.exe" }, + @{ Name = "GVFS.PostIndexChangedHook"; Dir = "GVFS.PostIndexChangedHook"; Exe = "GVFS.PostIndexChangedHook.exe" }, + @{ Name = "GVFS.VirtualFileSystemHook"; Dir = "GVFS.VirtualFileSystemHook"; Exe = "GVFS.VirtualFileSystemHook.exe" } +) + +# Map dotnet RID to MSBuild platform for native C++ projects +$RidToMSBuildPlatform = @{ + "win-x64" = "x64" + "win-arm64" = "ARM64" +} + +# Find MSBuild once +$msbuildExe = $null +$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" +if (Test-Path $vswhere) { + $vsPath = & $vswhere -latest -requires Microsoft.Component.MSBuild -property installationPath 2>$null + if ($vsPath) { + $msbuildExe = Join-Path $vsPath "MSBuild\Current\Bin\amd64\MSBuild.exe" + if (-not (Test-Path $msbuildExe)) { $msbuildExe = Join-Path $vsPath "MSBuild\Current\Bin\MSBuild.exe" } + } +} +if (-not $msbuildExe -or -not (Test-Path $msbuildExe)) { + $msbuildExe = (Get-Command msbuild.exe -EA 0).Source +} + +# ───────────────────────────────────────────────────────────────────────────── +# Build each architecture +# ───────────────────────────────────────────────────────────────────────────── +foreach ($rid in $Runtimes) { + # Output directory: append arch suffix when building both + $OutRoot = if ($Runtimes.Count -gt 1) { "${OutBase}-$($rid.Replace('win-',''))" } else { $OutBase } + $msbuildPlatform = $RidToMSBuildPlatform[$rid] + + Write-Host "===============================================" -ForegroundColor Cyan + Write-Host " Building for $rid → $OutRoot" -ForegroundColor Cyan + Write-Host "===============================================" -ForegroundColor Cyan + Write-Host "" + + if (-not $SkipBuild) { + Write-Host "--- Publishing managed projects ($rid) ---" -ForegroundColor Yellow + + foreach ($proj in $ManagedProjects) { + $csproj = Join-Path $GVFSRoot "$($proj.Dir)\$($proj.Name).csproj" + if (-not (Test-Path $csproj)) { + Write-Warning "Project not found: $csproj — skipping" + continue + } + + Write-Host " Publishing $($proj.Name)..." -NoNewline + $sw = [Diagnostics.Stopwatch]::StartNew() + + dotnet publish $csproj ` + -c $Configuration ` + -r $rid ` + --self-contained true ` + -o "$OutRoot" ` + 2>&1 | Out-Null + + if ($LASTEXITCODE -ne 0) { + Write-Host " FAILED" -ForegroundColor Red + throw "dotnet publish failed for $($proj.Name) ($rid)" + } + + $sw.Stop() + $size = if (Test-Path "$OutRoot\$($proj.Exe)") { + [math]::Round((Get-Item "$OutRoot\$($proj.Exe)").Length / 1MB, 1) + } else { "?" } + Write-Host " OK (${size}MB, $([math]::Round($sw.Elapsed.TotalSeconds, 1))s)" -ForegroundColor Green + } + + # Build native C++ projects + Write-Host "" + Write-Host " Building native hooks ($msbuildPlatform)..." -NoNewline + + if ($msbuildExe -and (Test-Path $msbuildExe)) { + foreach ($proj in $NativeProjects) { + $vcxproj = Get-ChildItem -Path $GVFSRoot -Recurse -Filter "$($proj.Name).vcxproj" | Select-Object -First 1 + if ($vcxproj) { + & $msbuildExe $vcxproj.FullName /p:Configuration=$Configuration /p:Platform=$msbuildPlatform /v:minimal /nologo 2>&1 | Out-Null + $nativeExe = Join-Path (Split-Path $RepoRoot) "out\$($proj.Name)\bin\$msbuildPlatform\$Configuration\$($proj.Exe)" + if (Test-Path $nativeExe) { + Copy-Item $nativeExe $OutRoot -Force + } + } + } + Write-Host " OK" -ForegroundColor Green + } else { + Write-Host " SKIPPED (MSBuild not found)" -ForegroundColor Yellow + } + } else { + Write-Host "--- Skipped build (using existing output) ---" -ForegroundColor DarkGray + } + + # ───────────────────────────────────────────────────────────────────── + # Verify layout + # ───────────────────────────────────────────────────────────────────── + Write-Host "" + Write-Host "--- Verifying layout ($rid) ---" -ForegroundColor Yellow + + New-Item -ItemType Directory -Path "$OutRoot\ProgramData\GVFS.Service" -Force | Out-Null + + $icon = Join-Path $GVFSRoot "GVFS\GitVirtualFileSystem.ico" + if (Test-Path $icon) { Copy-Item $icon $OutRoot -Force } + + "" | Out-File "$OutRoot\OnDiskVersion16CapableInstallation.dat" -Encoding ascii + + $allExes = ($ManagedProjects + $NativeProjects) | ForEach-Object { $_.Exe } + $missing = @() + foreach ($exe in $allExes) { + $path = Join-Path $OutRoot $exe + if (Test-Path $path) { + $size = [math]::Round((Get-Item $path).Length / 1MB, 1) + Write-Host " [OK] $exe (${size}MB)" -ForegroundColor Green + } else { + Write-Host " [MISSING] $exe" -ForegroundColor Red + $missing += $exe + } + } + + if ($missing.Count -gt 0) { + Write-Warning "Missing $($missing.Count) executable(s) for $rid." + } else { + $totalSize = [math]::Round((Get-ChildItem $OutRoot -File | Measure-Object Length -Sum).Sum / 1MB, 1) + Write-Host " Layout: $($allExes.Count) executables, ${totalSize}MB total" -ForegroundColor Cyan + } + + # ───────────────────────────────────────────────────────────────────── + # Optional: Build installer + # ───────────────────────────────────────────────────────────────────── + if ($BuildInstaller) { + Write-Host "" + Write-Host "--- Building Installer ($rid) ---" -ForegroundColor Yellow + + $iscc = $null + $nugetIscc = Get-ChildItem "$env:USERPROFILE\.nuget\packages\tools.innosetup" -Recurse -Filter "ISCC.exe" -EA 0 | Select-Object -First 1 + if ($nugetIscc) { $iscc = $nugetIscc.FullName } + if (-not $iscc) { + $progIscc = "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" + if (Test-Path $progIscc) { $iscc = $progIscc } + } + + if (-not $iscc) { + Write-Warning "Inno Setup compiler (ISCC.exe) not found." + } else { + $setupIss = Join-Path $GVFSRoot "GVFS.Installers\Setup.iss" + $archSuffix = $rid.Replace("win-", "") + $installerOut = Join-Path (Split-Path $OutRoot) "installer-$archSuffix" + New-Item -ItemType Directory -Path $installerOut -Force | Out-Null + + $versionStr = if (Test-Path "$OutRoot\GVFS.exe") { + (Get-Item "$OutRoot\GVFS.exe").VersionInfo.ProductVersion + } else { "0.0.0.0" } + + Write-Host " Version: $versionStr" + & $iscc /DLayoutDir="$OutRoot" /DGVFSVersion=$versionStr $setupIss /O"$installerOut" 2>&1 | Out-Null + + if ($LASTEXITCODE -eq 0) { + $installer = Get-ChildItem $installerOut -Filter "SetupGVFS*.exe" | Select-Object -First 1 + if ($installer) { + $instSize = [math]::Round($installer.Length / 1MB, 1) + Write-Host " Installer: $($installer.FullName) (${instSize}MB)" -ForegroundColor Green + } + } else { + Write-Warning "Installer build failed for $rid" + } + } + } + + Write-Host "" +} + +Write-Host "=== Done ===" -ForegroundColor Cyan +foreach ($rid in $Runtimes) { + $dir = if ($Runtimes.Count -gt 1) { "${OutBase}-$($rid.Replace('win-',''))" } else { $OutBase } + Write-Host " $rid → $dir" +}