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 ^ ^ ^ ^ ^
+
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