diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
index 440d753bf..1aaa7ba42 100644
--- a/.github/workflows/build.yaml
+++ b/.github/workflows/build.yaml
@@ -290,6 +290,13 @@ jobs:
if: steps.skip.outputs.result != 'true'
uses: microsoft/setup-msbuild@v3.0.0
+ - name: Install vcpkg native dependencies
+ if: steps.skip.outputs.result != 'true'
+ shell: cmd
+ run: |
+ "%VCPKG_INSTALLATION_ROOT%\vcpkg.exe" install --triplet x64-windows-static-aot --x-install-root=out\vcpkg_installed\static --x-manifest-root=src || exit /b 1
+ "%VCPKG_INSTALLATION_ROOT%\vcpkg.exe" install --triplet x64-windows-dynamic --x-install-root=out\vcpkg_installed\dynamic --x-manifest-root=src || exit /b 1
+
- name: Build VFS for Git
if: steps.skip.outputs.result != 'true'
shell: cmd
diff --git a/.github/workflows/functional-tests.yaml b/.github/workflows/functional-tests.yaml
index 72c9ee503..046003d9f 100644
--- a/.github/workflows/functional-tests.yaml
+++ b/.github/workflows/functional-tests.yaml
@@ -77,7 +77,19 @@ jobs:
return true
- name: Download Git installer
+ id: download-git
if: steps.skip.outputs.result != 'true'
+ continue-on-error: true
+ uses: actions/download-artifact@v8
+ with:
+ name: ${{ inputs.git_artifact_name }}
+ path: git
+ repository: ${{ inputs.git_repository || github.repository }}
+ run-id: ${{ inputs.git_run_id || github.run_id }}
+ github-token: ${{ secrets.git_token || github.token }}
+
+ - name: Download Git installer (retry)
+ if: steps.skip.outputs.result != 'true' && steps.download-git.outcome == 'failure'
uses: actions/download-artifact@v8
with:
name: ${{ inputs.git_artifact_name }}
@@ -87,7 +99,19 @@ jobs:
github-token: ${{ secrets.git_token || github.token }}
- name: Download GVFS installer
+ id: download-gvfs
if: steps.skip.outputs.result != 'true'
+ continue-on-error: true
+ uses: actions/download-artifact@v8
+ with:
+ name: GVFS_${{ matrix.configuration }}
+ path: gvfs
+ repository: ${{ inputs.vfs_repository || github.repository }}
+ run-id: ${{ inputs.vfs_run_id || github.run_id }}
+ github-token: ${{ secrets.vfs_token || github.token }}
+
+ - name: Download GVFS installer (retry)
+ if: steps.skip.outputs.result != 'true' && steps.download-gvfs.outcome == 'failure'
uses: actions/download-artifact@v8
with:
name: GVFS_${{ matrix.configuration }}
@@ -97,7 +121,19 @@ jobs:
github-token: ${{ secrets.vfs_token || github.token }}
- name: Download functional tests drop
+ id: download-ft
if: steps.skip.outputs.result != 'true'
+ continue-on-error: true
+ uses: actions/download-artifact@v8
+ with:
+ name: FunctionalTests_${{ matrix.configuration }}
+ path: ft
+ repository: ${{ inputs.vfs_repository || github.repository }}
+ run-id: ${{ inputs.vfs_run_id || github.run_id }}
+ github-token: ${{ secrets.vfs_token || github.token }}
+
+ - name: Download functional tests drop (retry)
+ if: steps.skip.outputs.result != 'true' && steps.download-ft.outcome == 'failure'
uses: actions/download-artifact@v8
with:
name: FunctionalTests_${{ matrix.configuration }}
diff --git a/.github/workflows/upgrade-tests.yaml b/.github/workflows/upgrade-tests.yaml
index 44eaaac94..74e34f7e9 100644
--- a/.github/workflows/upgrade-tests.yaml
+++ b/.github/workflows/upgrade-tests.yaml
@@ -33,6 +33,7 @@ jobs:
- double-staging
- staging-then-clean
- mount-safety-deferral
+ - unmount-all-triggers-upgrade
fail-fast: false
steps:
@@ -62,14 +63,32 @@ jobs:
gh release download $tag --repo microsoft/VFSForGit --pattern "SetupGVFS*.exe" --dir gvfs-lkg
- name: Download Git installer
+ id: download-git
if: steps.skip.outputs.result != 'true'
+ continue-on-error: true
+ uses: actions/download-artifact@v8
+ with:
+ name: MicrosoftGit
+ path: git
+
+ - name: Download Git installer (retry)
+ if: steps.skip.outputs.result != 'true' && steps.download-git.outcome == 'failure'
uses: actions/download-artifact@v8
with:
name: MicrosoftGit
path: git
- name: Download current GVFS installer
+ id: download-gvfs
if: steps.skip.outputs.result != 'true'
+ continue-on-error: true
+ uses: actions/download-artifact@v8
+ with:
+ name: GVFS_${{ matrix.configuration }}
+ path: gvfs-new
+
+ - name: Download current GVFS installer (retry)
+ if: steps.skip.outputs.result != 'true' && steps.download-gvfs.outcome == 'failure'
uses: actions/download-artifact@v8
with:
name: GVFS_${{ matrix.configuration }}
@@ -172,6 +191,36 @@ jobs:
}
}
+ function Assert-HookVersionsMatch {
+ Write-Host "Verifying hook binary versions in enlistment..."
+ $hooksDir = Join-Path $enlistment "src\.git\hooks"
+
+ # Native hooks: each has its own source binary in the install directory
+ # Command hooks: copies of GitHooksLoader.exe
+ $hooks = @(
+ @{ Source = "GVFS.ReadObjectHook.exe"; Target = "read-object.exe" }
+ @{ Source = "GVFS.VirtualFileSystemHook.exe"; Target = "virtual-filesystem.exe" }
+ @{ Source = "GVFS.PostIndexChangedHook.exe"; Target = "post-index-change.exe" }
+ @{ Source = "GitHooksLoader.exe"; Target = "pre-command.exe" }
+ @{ Source = "GitHooksLoader.exe"; Target = "post-command.exe" }
+ )
+
+ foreach ($hook in $hooks) {
+ $sourcePath = Join-Path $installDir $hook.Source
+ $targetPath = Join-Path $hooksDir $hook.Target
+ if (-not (Test-Path $targetPath)) {
+ throw "Hook not found: $targetPath"
+ }
+ $sourceVer = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($sourcePath).FileVersion
+ $targetVer = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($targetPath).FileVersion
+ if ($sourceVer -ne $targetVer) {
+ throw "Hook version mismatch: $($hook.Target) is $targetVer, expected $sourceVer (from $($hook.Source))"
+ }
+ Write-Host " $($hook.Target): $targetVer OK"
+ }
+ Write-Host "All hook binary versions match"
+ }
+
# =============================================
# Test scenarios
# =============================================
@@ -192,6 +241,11 @@ jobs:
Unmount-TestRepo
Restart-Service
Assert-PendingUpgrade $false
+
+ # Remount and verify all hooks were updated to new version
+ $null = Mount-TestRepo
+ Assert-HookVersionsMatch
+ Unmount-TestRepo
Write-Host "PASS: Staging upgrade completed"
}
@@ -204,6 +258,11 @@ jobs:
Install-GVFS $newInstaller @("/STAGEIFMOUNTED=false")
Assert-PendingUpgrade $false
Assert-ServiceRunning
+
+ # Remount and verify all hooks were updated to new version
+ $null = Mount-TestRepo
+ Assert-HookVersionsMatch
+ Unmount-TestRepo
Write-Host "PASS: Clean upgrade completed"
}
@@ -274,6 +333,36 @@ jobs:
Write-Host "PASS: Mount safety deferral works correctly"
}
+ "unmount-all-triggers-upgrade" {
+ Write-Host "=== Scenario: unmount-all triggers staged upgrade ==="
+ # Install LKG, mount, staging upgrade with new installer (which
+ # replaces GVFS.Service.exe in-place with the new version that
+ # includes PendingUpgradeMonitor). Then unmount via --unmount-all.
+ # The new service monitors mount process exits and applies the
+ # upgrade automatically — no pipe message from gvfs.exe needed.
+ Install-GVFS $lkgInstaller
+ Assert-ServiceRunning
+ $mountPid = Mount-TestRepo
+
+ Install-GVFS $newInstaller @("/STAGEIFMOUNTED=true")
+ Assert-MountAlive $mountPid
+ Assert-PendingUpgrade $true
+
+ # Unmount via --unmount-all (uses LKG gvfs.exe — no new pipe msg)
+ & "$installDir\gvfs.exe" service --unmount-all 2>&1 | Write-Host
+ if ($LASTEXITCODE -ne 0) { throw "unmount-all failed" }
+
+ # The monitor's debounce timer fires 5s after the last mount
+ # process exits, then applies the upgrade. Wait for completion.
+ $deadline = (Get-Date).AddSeconds(30)
+ while ((Test-Path "$installDir\PendingUpgrade") -and (Get-Date) -lt $deadline) {
+ Start-Sleep -Seconds 2
+ }
+
+ Assert-PendingUpgrade $false
+ Write-Host "PASS: unmount-all triggers staged upgrade via process monitor"
+ }
+
default {
throw "Unknown scenario: ${{ matrix.scenario }}"
}
diff --git a/.gitignore b/.gitignore
index 7567b5ede..e2d06f87d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -228,4 +228,5 @@ ModelManifest.xml
.vscode/
# ProjFS Kext Unit Test coverage results
-ProjFS.Mac/CoverageResult.txt
\ No newline at end of file
+ProjFS.Mac/CoverageResult.txt
+
diff --git a/Directory.Build.props b/Directory.Build.props
index 16a5a8c68..89953442c 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -33,6 +33,74 @@
$(ProjectOutPath)obj\
+
+
+ $(RepoOutPath)vcpkg_installed\static\x64-windows-static-aot\
+
+
+
+
+ git2
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+ PreserveNewest
+ git2.dll
+
+
+ PreserveNewest
+ PreserveNewest
+ pcre.dll
+
+
+ PreserveNewest
+ PreserveNewest
+ z.dll
+
+
+
x64
diff --git a/Directory.Build.targets b/Directory.Build.targets
index 272ea11ec..d04b30b4f 100644
--- a/Directory.Build.targets
+++ b/Directory.Build.targets
@@ -12,6 +12,75 @@
+
+
+ <_VcpkgManifestFile>$(RepoSrcPath)vcpkg.json
+ <_VcpkgConfigFile>$(RepoSrcPath)vcpkg-configuration.json
+ <_VcpkgStampFile>$(RepoOutPath)vcpkg_installed\.msbuildstamp
+
+
+
+ <_VcpkgManifestInputs Include="$(_VcpkgManifestFile)" />
+ <_VcpkgManifestInputs Include="$(_VcpkgConfigFile)" Condition="Exists('$(_VcpkgConfigFile)')" />
+
+
+
+
+
+
+ <_VcpkgExe Condition="Exists('$(VCPKG_INSTALLATION_ROOT)\vcpkg.exe')">$(VCPKG_INSTALLATION_ROOT)\vcpkg.exe
+ <_VcpkgExe Condition="'$(_VcpkgExe)' == '' and Exists('$(VsInstallRoot)\VC\vcpkg\vcpkg.exe')">$(VsInstallRoot)\VC\vcpkg\vcpkg.exe
+ <_VcpkgManifestArg>--x-manifest-root="$(RepoSrcPath.TrimEnd('\'))"
+
+
+
+
+
+
+
+ <_VcpkgExe>$(_VsInstallPath)\VC\vcpkg\vcpkg.exe
+
+
+
+
+ <_VcpkgExe>vcpkg
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
diff --git a/GVFS/FastFetch/CheckoutStage.cs b/GVFS/FastFetch/CheckoutStage.cs
index 6874eff43..1012b29ff 100644
--- a/GVFS/FastFetch/CheckoutStage.cs
+++ b/GVFS/FastFetch/CheckoutStage.cs
@@ -173,13 +173,25 @@ private void HandleAllDirectoryOperations()
case DiffTreeResult.Operations.Add:
try
{
- Directory.CreateDirectory(absoluteTargetPath);
+ if (treeOp.SourcePath != null)
+ {
+ this.ApplyCaseOnlyDirectoryRename(treeOp, absoluteTargetPath);
+ }
+ else
+ {
+ Directory.CreateDirectory(absoluteTargetPath);
+ }
}
catch (Exception ex)
{
EventMetadata metadata = new EventMetadata();
- metadata.Add("Operation", "CreateDirectory");
+ metadata.Add("Operation", treeOp.SourcePath != null ? "RenameDirectory" : "CreateDirectory");
metadata.Add(nameof(treeOp.TargetPath), absoluteTargetPath);
+ if (treeOp.SourcePath != null)
+ {
+ metadata.Add(nameof(treeOp.SourcePath), treeOp.SourcePath);
+ }
+
this.tracer.RelatedError(metadata, ex.Message);
this.HasFailures = true;
}
@@ -222,6 +234,62 @@ private void HandleAllDirectoryOperations()
}
}
+ ///
+ /// Apply a case-only directory rename produced by DiffHelper, where
+ /// .SourcePath carries the old casing and
+ /// is the new (post-rename) absolute path.
+ ///
+ /// Directory.Move throws IOException for case-only renames on Windows, so the
+ /// rename is performed in two steps through a temporary name. If the second
+ /// move fails the directory is moved back to the original casing so a retry
+ /// sees a consistent working tree.
+ ///
+ /// If the source directory is missing it usually means an outer parent rename
+ /// has already moved the children into place (Windows preserves child casing
+ /// through a parent rename when the children's tree SHAs were unchanged); the
+ /// fallback creates the target directory so the operation is idempotent.
+ /// Exceptions propagate to the caller's existing error handler.
+ ///
+ private void ApplyCaseOnlyDirectoryRename(DiffTreeResult treeOp, string absoluteTargetPath)
+ {
+ string absoluteSourcePath = Path.Combine(this.enlistment.WorkingDirectoryBackingRoot, treeOp.SourcePath);
+ if (!Directory.Exists(absoluteSourcePath))
+ {
+ Directory.CreateDirectory(absoluteTargetPath);
+ return;
+ }
+
+ string trimmedSourcePath = absoluteSourcePath.TrimEnd(Path.DirectorySeparatorChar);
+ string trimmedTargetPath = absoluteTargetPath.TrimEnd(Path.DirectorySeparatorChar);
+ string tempPath = trimmedTargetPath + "_caseRename_" + Guid.NewGuid().ToString("N");
+
+ Directory.Move(trimmedSourcePath, tempPath);
+ try
+ {
+ Directory.Move(tempPath, trimmedTargetPath);
+ }
+ catch
+ {
+ // The first move succeeded but the second failed. Try to restore the
+ // original casing so a retry starts from a consistent state; if
+ // restoration also fails, the outer catch will log the original
+ // exception and the temp directory will be left behind for manual
+ // recovery.
+ if (Directory.Exists(tempPath) && !Directory.Exists(trimmedSourcePath))
+ {
+ try
+ {
+ Directory.Move(tempPath, trimmedSourcePath);
+ }
+ catch
+ {
+ }
+ }
+
+ throw;
+ }
+ }
+
private void HandleAllFileDeleteOperations()
{
string path;
diff --git a/GVFS/GVFS.Common/Database/PlaceholderTable.cs b/GVFS/GVFS.Common/Database/PlaceholderTable.cs
index 0813a1fb1..c0d98d293 100644
--- a/GVFS/GVFS.Common/Database/PlaceholderTable.cs
+++ b/GVFS/GVFS.Common/Database/PlaceholderTable.cs
@@ -2,16 +2,17 @@
using System.Collections.Generic;
using System.Data;
using System.IO;
+using System.Threading;
namespace GVFS.Common.Database
{
///
- /// This class is for interacting with the Placeholder table in the SQLite database
+ /// This class is for interacting with the Placeholder tablein the SQLite database
///
public class PlaceholderTable : IPlaceholderCollection
{
private IGVFSConnectionPool connectionPool;
- private object writerLock = new object();
+ private Lock writerLock = new Lock();
public PlaceholderTable(IGVFSConnectionPool connectionPool)
{
diff --git a/GVFS/GVFS.Common/Database/SparseTable.cs b/GVFS/GVFS.Common/Database/SparseTable.cs
index 5ead9baed..4a7f3db46 100644
--- a/GVFS/GVFS.Common/Database/SparseTable.cs
+++ b/GVFS/GVFS.Common/Database/SparseTable.cs
@@ -2,13 +2,14 @@
using System.Collections.Generic;
using System.Data;
using System.IO;
+using System.Threading;
namespace GVFS.Common.Database
{
public class SparseTable : ISparseCollection
{
private IGVFSConnectionPool connectionPool;
- private object writerLock = new object();
+ private Lock writerLock = new Lock();
public SparseTable(IGVFSConnectionPool connectionPool)
{
diff --git a/GVFS/GVFS.Common/FileBasedCollection.cs b/GVFS/GVFS.Common/FileBasedCollection.cs
index 1956c086e..1ebe09b33 100644
--- a/GVFS/GVFS.Common/FileBasedCollection.cs
+++ b/GVFS/GVFS.Common/FileBasedCollection.cs
@@ -27,7 +27,7 @@ public abstract class FileBasedCollection : IDisposable
///
private readonly bool collectionAppendsDirectlyToFile;
- private readonly object fileLock = new object();
+ private readonly Lock fileLock = new Lock();
private readonly PhysicalFileSystem fileSystem;
private readonly string dataDirectoryPath;
diff --git a/GVFS/GVFS.Common/FileSystem/HooksInstaller.cs b/GVFS/GVFS.Common/FileSystem/HooksInstaller.cs
index 7ebd28eec..f7dfae0fc 100644
--- a/GVFS/GVFS.Common/FileSystem/HooksInstaller.cs
+++ b/GVFS/GVFS.Common/FileSystem/HooksInstaller.cs
@@ -103,6 +103,27 @@ public static bool TryUpdateHooks(GVFSContext context, out string errorMessage)
}
}
+ // Update the pre-command and post-command hook loaders (GitHooksLoader copies).
+ // These are deployed at clone time by InstallHooks but also need updating on
+ // mount so that upgrading GVFS and remounting refreshes all hooks.
+ string loaderSourcePath = Path.Combine(ExecutingDirectory, GVFSConstants.DotGit.Hooks.LoaderExecutable);
+
+ string precommandHookPath = Path.Combine(
+ context.Enlistment.WorkingDirectoryBackingRoot,
+ GVFSConstants.DotGit.Hooks.PreCommandPath + GVFSPlatform.Instance.Constants.ExecutableExtension);
+ if (!TryUpdateHook(context, GVFSConstants.DotGit.Hooks.PreCommandHookName, loaderSourcePath, precommandHookPath, out errorMessage))
+ {
+ return false;
+ }
+
+ string postcommandHookPath = Path.Combine(
+ context.Enlistment.WorkingDirectoryBackingRoot,
+ GVFSConstants.DotGit.Hooks.PostCommandPath + GVFSPlatform.Instance.Constants.ExecutableExtension);
+ if (!TryUpdateHook(context, GVFSConstants.DotGit.Hooks.PostCommandHookName, loaderSourcePath, postcommandHookPath, out errorMessage))
+ {
+ return false;
+ }
+
return true;
}
@@ -161,13 +182,23 @@ private static bool TryUpdateHook(
HookData hook,
out string errorMessage)
{
- bool copyHook = false;
string enlistmentHookPath = Path.Combine(context.Enlistment.WorkingDirectoryBackingRoot, hook.Path + GVFSPlatform.Instance.Constants.ExecutableExtension);
string installedHookPath = Path.Combine(ExecutingDirectory, hook.ExecutableName);
+ return TryUpdateHook(context, hook.Name, installedHookPath, enlistmentHookPath, out errorMessage);
+ }
+
+ private static bool TryUpdateHook(
+ GVFSContext context,
+ string hookName,
+ string installedHookPath,
+ string enlistmentHookPath,
+ out string errorMessage)
+ {
+ bool copyHook = false;
if (!context.FileSystem.FileExists(installedHookPath))
{
- errorMessage = hook.ExecutableName + " cannot be found at " + installedHookPath;
+ errorMessage = Path.GetFileName(installedHookPath) + " cannot be found at " + installedHookPath;
return false;
}
@@ -179,8 +210,8 @@ private static bool TryUpdateHook(
metadata.Add("Area", "Mount");
metadata.Add(nameof(enlistmentHookPath), enlistmentHookPath);
metadata.Add(nameof(installedHookPath), installedHookPath);
- metadata.Add(TracingConstants.MessageKey.WarningMessage, hook.Name + " not found in enlistment, copying from installation folder");
- context.Tracer.RelatedWarning(hook.Name + " MissingFromEnlistment", metadata);
+ metadata.Add(TracingConstants.MessageKey.WarningMessage, hookName + " not found in enlistment, copying from installation folder");
+ context.Tracer.RelatedWarning(hookName + " MissingFromEnlistment", metadata);
}
else
{
@@ -197,8 +228,8 @@ private static bool TryUpdateHook(
metadata.Add(nameof(enlistmentHookPath), enlistmentHookPath);
metadata.Add(nameof(installedHookPath), installedHookPath);
metadata.Add("Exception", e.ToString());
- context.Tracer.RelatedError(metadata, "Failed to compare " + hook.Name + " version");
- errorMessage = "Error comparing " + hook.Name + " versions. " + ConsoleHelper.GetGVFSLogMessage(context.Enlistment.EnlistmentRoot);
+ context.Tracer.RelatedError(metadata, "Failed to compare " + hookName + " version");
+ errorMessage = "Error comparing " + hookName + " versions. " + ConsoleHelper.GetGVFSLogMessage(context.Enlistment.EnlistmentRoot);
return false;
}
}
@@ -216,8 +247,8 @@ private static bool TryUpdateHook(
metadata.Add(nameof(enlistmentHookPath), enlistmentHookPath);
metadata.Add(nameof(installedHookPath), installedHookPath);
metadata.Add("Exception", e.ToString());
- context.Tracer.RelatedError(metadata, "Failed to copy " + hook.Name + " to enlistment");
- errorMessage = "Error copying " + hook.Name + " to enlistment. " + ConsoleHelper.GetGVFSLogMessage(context.Enlistment.EnlistmentRoot);
+ context.Tracer.RelatedError(metadata, "Failed to copy " + hookName + " to enlistment");
+ errorMessage = "Error copying " + hookName + " to enlistment. " + ConsoleHelper.GetGVFSLogMessage(context.Enlistment.EnlistmentRoot);
return false;
}
}
diff --git a/GVFS/GVFS.Common/GVFS.Common.csproj b/GVFS/GVFS.Common/GVFS.Common.csproj
index 4005b2eca..f8d0c8a4e 100644
--- a/GVFS/GVFS.Common/GVFS.Common.csproj
+++ b/GVFS/GVFS.Common/GVFS.Common.csproj
@@ -5,7 +5,6 @@
-
diff --git a/GVFS/GVFS.Common/GVFSLock.cs b/GVFS/GVFS.Common/GVFSLock.cs
index b7324d74c..5bfaa5976 100644
--- a/GVFS/GVFS.Common/GVFSLock.cs
+++ b/GVFS/GVFS.Common/GVFSLock.cs
@@ -8,7 +8,7 @@ namespace GVFS.Common
{
public partial class GVFSLock
{
- private readonly object acquisitionLock = new object();
+ private readonly Lock acquisitionLock = new Lock();
private readonly ITracer tracer;
private readonly LockHolder currentLockHolder = new LockHolder();
diff --git a/GVFS/GVFS.Common/GVFSPlatform.cs b/GVFS/GVFS.Common/GVFSPlatform.cs
index 6723177ba..579e94955 100644
--- a/GVFS/GVFS.Common/GVFSPlatform.cs
+++ b/GVFS/GVFS.Common/GVFSPlatform.cs
@@ -4,6 +4,7 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
+using System.Diagnostics;
using System.IO;
using System.IO.Pipes;
@@ -51,7 +52,7 @@ public static void Register(GVFSPlatform platform)
/// This method should only be called by processes whose code we own as the background process must
/// do some extra work after it starts.
///
- public abstract void StartBackgroundVFS4GProcess(ITracer tracer, string programName, string[] args);
+ public abstract Process StartBackgroundVFS4GProcess(ITracer tracer, string programName, string[] args);
///
/// Adjusts the current process for running in the background.
diff --git a/GVFS/GVFS.Common/Git/DiffTreeResult.cs b/GVFS/GVFS.Common/Git/DiffTreeResult.cs
index 85eaaf559..abafa4597 100644
--- a/GVFS/GVFS.Common/Git/DiffTreeResult.cs
+++ b/GVFS/GVFS.Common/Git/DiffTreeResult.cs
@@ -38,6 +38,18 @@ public enum Operations
public ushort SourceMode { get; set; }
public ushort TargetMode { get; set; }
+ ///
+ /// Old-cased path of a case-only directory rename, set by DiffHelper when
+ /// collapsing a Delete+Add pair under the case-insensitive comparer. When
+ /// non-null the operation represents a rename from SourcePath to TargetPath
+ /// and consumers (currently CheckoutStage) must rename the directory on
+ /// disk instead of treating the operation as a plain Add. Always null for
+ /// file operations, Modify, Delete, and non-rename Add entries. The setter
+ /// is intentionally restricted to the assembly so only the parser can
+ /// produce this annotation.
+ ///
+ public string SourcePath { get; internal set; }
+
public static DiffTreeResult ParseFromDiffTreeLine(string line)
{
if (string.IsNullOrEmpty(line))
diff --git a/GVFS/GVFS.Common/Git/GitAuthentication.cs b/GVFS/GVFS.Common/Git/GitAuthentication.cs
index fff37ad9a..faaafba07 100644
--- a/GVFS/GVFS.Common/Git/GitAuthentication.cs
+++ b/GVFS/GVFS.Common/Git/GitAuthentication.cs
@@ -6,6 +6,7 @@
using System.Net.Http;
using System.Security.Cryptography.X509Certificates;
using System.Text;
+using System.Threading;
namespace GVFS.Common.Git
{
@@ -13,7 +14,7 @@ public class GitAuthentication
{
private const double MaxBackoffSeconds = 30;
- private readonly object gitAuthLock = new object();
+ private readonly Lock gitAuthLock = new Lock();
private readonly ICredentialStore credentialStore;
private readonly string repoUrl;
@@ -199,7 +200,8 @@ public bool TryInitialize(ITracer tracer, Enlistment enlistment, out string erro
enlistment,
new RetryConfig(),
out _,
- out errorMessage);
+ out errorMessage,
+ out _);
}
///
@@ -216,7 +218,8 @@ public bool TryInitializeAndQueryGVFSConfig(
Enlistment enlistment,
RetryConfig retryConfig,
out ServerGVFSConfig serverGVFSConfig,
- out string errorMessage)
+ out string errorMessage,
+ out bool isAuthFailure)
{
if (this.isInitialized)
{
@@ -225,6 +228,7 @@ public bool TryInitializeAndQueryGVFSConfig(
serverGVFSConfig = null;
errorMessage = null;
+ isAuthFailure = false;
using (ConfigHttpRequestor configRequestor = new ConfigHttpRequestor(tracer, enlistment, retryConfig))
{
@@ -252,6 +256,7 @@ public bool TryInitializeAndQueryGVFSConfig(
if (!this.TryCallGitCredential(tracer, out errorMessage))
{
+ isAuthFailure = true;
tracer.RelatedWarning("{0}: Credential fetch failed: {1}", nameof(this.TryInitializeAndQueryGVFSConfig), errorMessage);
return false;
}
@@ -259,12 +264,18 @@ public bool TryInitializeAndQueryGVFSConfig(
this.isInitialized = true;
// Retry with credentials using the same ConfigHttpRequestor (reuses HttpClient/connection)
- if (configRequestor.TryQueryGVFSConfig(true, out serverGVFSConfig, out _, out errorMessage))
+ HttpStatusCode? retryHttpStatus;
+ if (configRequestor.TryQueryGVFSConfig(true, out serverGVFSConfig, out retryHttpStatus, out errorMessage))
{
tracer.RelatedInfo("{0}: Config obtained with credentials", nameof(this.TryInitializeAndQueryGVFSConfig));
return true;
}
+ if (retryHttpStatus == HttpStatusCode.Unauthorized || retryHttpStatus == HttpStatusCode.Forbidden)
+ {
+ isAuthFailure = true;
+ }
+
tracer.RelatedWarning("{0}: Config query failed with credentials: {1}", nameof(this.TryInitializeAndQueryGVFSConfig), errorMessage);
return false;
}
diff --git a/GVFS/GVFS.Common/Git/GitProcess.cs b/GVFS/GVFS.Common/Git/GitProcess.cs
index a6ac3c748..caca4df64 100644
--- a/GVFS/GVFS.Common/Git/GitProcess.cs
+++ b/GVFS/GVFS.Common/Git/GitProcess.cs
@@ -7,6 +7,7 @@
using System.IO;
using System.Linq;
using System.Text;
+using System.Threading;
namespace GVFS.Common.Git
{
@@ -21,14 +22,14 @@ public class GitProcess : ICredentialStore
///
/// Lock taken for duration of running executingProcess.
///
- private object executionLock = new object();
+ private Lock executionLock = new Lock();
///
/// Lock taken when changing the running state of executingProcess.
///
/// Can be taken within executionLock.
///
- private object processLock = new object();
+ private Lock processLock = new Lock();
private string gitBinPath;
private string workingDirectoryRoot;
diff --git a/GVFS/GVFS.Common/Git/LibGit2Repo.cs b/GVFS/GVFS.Common/Git/LibGit2Repo.cs
index 60e69450d..f0e7bc464 100644
--- a/GVFS/GVFS.Common/Git/LibGit2Repo.cs
+++ b/GVFS/GVFS.Common/Git/LibGit2Repo.cs
@@ -483,7 +483,7 @@ public static string GetLastError()
return Marshal.PtrToStructure(ptr).Message;
}
- [DllImport(Git2NativeLibName, EntryPoint = "giterr_last")]
+ [DllImport(Git2NativeLibName, EntryPoint = "git_error_last")]
private static extern IntPtr GetLastGitError();
[StructLayout(LayoutKind.Sequential)]
diff --git a/GVFS/GVFS.Common/Git/LibGit2RepoInvoker.cs b/GVFS/GVFS.Common/Git/LibGit2RepoInvoker.cs
index 44b084049..f3fdf83b3 100644
--- a/GVFS/GVFS.Common/Git/LibGit2RepoInvoker.cs
+++ b/GVFS/GVFS.Common/Git/LibGit2RepoInvoker.cs
@@ -8,7 +8,7 @@ public class LibGit2RepoInvoker : IDisposable
{
private readonly Func createRepo;
private readonly ITracer tracer;
- private readonly object sharedRepoLock = new object();
+ private readonly Lock sharedRepoLock = new Lock();
private volatile bool disposing;
private volatile int activeCallers;
private LibGit2Repo sharedRepo;
diff --git a/GVFS/GVFS.Common/GitStatusCache.cs b/GVFS/GVFS.Common/GitStatusCache.cs
index efe13a49d..7323ec209 100644
--- a/GVFS/GVFS.Common/GitStatusCache.cs
+++ b/GVFS/GVFS.Common/GitStatusCache.cs
@@ -56,7 +56,7 @@ public class GitStatusCache : IDisposable
private volatile CacheState cacheState = CacheState.Dirty;
- private object cacheFileLock = new object();
+ private Lock cacheFileLock = new Lock();
internal static bool? TEST_EnableHydrationSummaryOverride = null;
@@ -597,7 +597,7 @@ private bool TryRebuildStatusCache()
private bool TryDeleteStatusCacheFile()
{
- Debug.Assert(Monitor.IsEntered(this.cacheFileLock), "Attempting to delete the git status cache file without the cacheFileLock");
+ Debug.Assert(this.cacheFileLock.IsHeldByCurrentThread, "Attempting to delete the git status cache file without the cacheFileLock");
try
{
@@ -635,7 +635,7 @@ private bool TryDeleteStatusCacheFile()
/// True on success, False on failure
private bool MoveCacheFileToFinalLocation(string tmpStatusFilePath)
{
- Debug.Assert(Monitor.IsEntered(this.cacheFileLock), "Attempting to update the git status cache file without the cacheFileLock");
+ Debug.Assert(this.cacheFileLock.IsHeldByCurrentThread, "Attempting to update the git status cache file without the cacheFileLock");
try
{
diff --git a/GVFS/GVFS.Common/Maintenance/GitMaintenanceQueue.cs b/GVFS/GVFS.Common/Maintenance/GitMaintenanceQueue.cs
index 5ecfa63ff..26d2f8431 100644
--- a/GVFS/GVFS.Common/Maintenance/GitMaintenanceQueue.cs
+++ b/GVFS/GVFS.Common/Maintenance/GitMaintenanceQueue.cs
@@ -8,7 +8,7 @@ namespace GVFS.Common.Maintenance
{
public class GitMaintenanceQueue
{
- private readonly object queueLock = new object();
+ private readonly Lock queueLock = new Lock();
private GVFSContext context;
private BlockingCollection queue = new BlockingCollection();
private GitMaintenanceStep currentStep;
diff --git a/GVFS/GVFS.Common/Maintenance/GitMaintenanceStep.cs b/GVFS/GVFS.Common/Maintenance/GitMaintenanceStep.cs
index 29bb3e9e4..ae14ab4ce 100644
--- a/GVFS/GVFS.Common/Maintenance/GitMaintenanceStep.cs
+++ b/GVFS/GVFS.Common/Maintenance/GitMaintenanceStep.cs
@@ -4,13 +4,14 @@
using System;
using System.Collections.Generic;
using System.IO;
+using System.Threading;
namespace GVFS.Common.Maintenance
{
public abstract class GitMaintenanceStep
{
public const string ObjectCacheLock = "git-maintenance-step.lock";
- private readonly object gitProcessLock = new object();
+ private readonly Lock gitProcessLock = new Lock();
public GitMaintenanceStep(GVFSContext context, bool requireObjectCacheLock, GitProcessChecker gitProcessChecker = null)
{
diff --git a/GVFS/GVFS.Common/MissingTreeTracker.cs b/GVFS/GVFS.Common/MissingTreeTracker.cs
index 3d5ca78a1..fe17c9890 100644
--- a/GVFS/GVFS.Common/MissingTreeTracker.cs
+++ b/GVFS/GVFS.Common/MissingTreeTracker.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
+using System.Threading;
using GVFS.Common.Tracing;
namespace GVFS.Common
@@ -16,7 +17,7 @@ public class MissingTreeTracker
private readonly int treeCapacity;
private readonly ITracer tracer;
- private readonly object syncLock = new object();
+ private readonly Lock syncLock = new Lock();
// Primary storage: commit -> set of missing trees
private readonly Dictionary> missingTreesByCommit;
diff --git a/GVFS/GVFS.Common/NativeMethods.cs b/GVFS/GVFS.Common/NativeMethods.cs
index 01282679e..1fae6876d 100644
--- a/GVFS/GVFS.Common/NativeMethods.cs
+++ b/GVFS/GVFS.Common/NativeMethods.cs
@@ -247,6 +247,19 @@ private static extern bool DeviceIoControl(
[DllImport("kernel32.dll")]
private static extern ulong GetTickCount64();
+ [DllImport("kernel32.dll")]
+ private static extern int WTSGetActiveConsoleSessionId();
+
+ ///
+ /// Returns the session ID of the physical console session, or -1 if
+ /// no interactive session is active (e.g. at boot before logon).
+ ///
+ public static int GetActiveConsoleSessionId()
+ {
+ int sessionId = WTSGetActiveConsoleSessionId();
+ return sessionId == unchecked((int)0xFFFFFFFF) ? -1 : sessionId;
+ }
+
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool SetFileTime(
SafeFileHandle hFile,
diff --git a/GVFS/GVFS.Common/Prefetch/Git/DiffHelper.cs b/GVFS/GVFS.Common/Prefetch/Git/DiffHelper.cs
index f55d47d5f..386e4c214 100644
--- a/GVFS/GVFS.Common/Prefetch/Git/DiffHelper.cs
+++ b/GVFS/GVFS.Common/Prefetch/Git/DiffHelper.cs
@@ -16,13 +16,28 @@ public class DiffHelper
private HashSet exactFileList;
private List patternList;
private List folderList;
- private HashSet filesAdded = new HashSet(GVFSPlatform.Instance.Constants.PathComparer);
-
- private HashSet stagedDirectoryOperations = new HashSet(new DiffTreeByNameComparer());
- private HashSet stagedFileDeletes = new HashSet(GVFSPlatform.Instance.Constants.PathComparer);
+ // The staged collections are keyed by the case-insensitive PathComparer on
+ // case-insensitive platforms so that two paths differing only in case map to
+ // the same entry. The dictionary value stores the original casing of the
+ // first path inserted, which case-rename detection compares against the
+ // incoming path to decide whether the collision is a rename or a true
+ // duplicate. Dictionary lookups keep this O(1); a HashSet would force a
+ // linear scan to recover the stored casing.
+ private Dictionary filesAdded = new Dictionary(GVFSPlatform.Instance.Constants.PathComparer);
+
+ private Dictionary stagedDirectoryOperations = new Dictionary(GVFSPlatform.Instance.Constants.PathComparer);
+ private Dictionary stagedFileDeletes = new Dictionary(GVFSPlatform.Instance.Constants.PathComparer);
+
+ // Holds the old-cased paths of directories whose Delete was collapsed into an
+ // Add via case-only rename detection. FlushStagedQueues consults this set to
+ // suppress child operations (file deletes and child directory Adds) whose
+ // parent was case-renamed — those children are carried by the parent rename
+ // on disk and do not need separate operations.
+ private HashSet directoriesReplacedByCaseRename = new HashSet(GVFSPlatform.Instance.Constants.PathComparer);
private Enlistment enlistment;
private GitProcess git;
+ private bool diffPerformed;
public DiffHelper(ITracer tracer, Enlistment enlistment, IEnumerable fileList, IEnumerable folderList, bool includeSymLinks)
: this(tracer, enlistment, new GitProcess(enlistment), fileList, folderList, includeSymLinks)
@@ -93,6 +108,7 @@ public void PerformDiff(string targetCommitSha)
public void PerformDiff(string sourceTreeSha, string targetTreeSha)
{
+ this.EnsureSingleUse();
EventMetadata metadata = new EventMetadata();
metadata.Add("TargetTreeSha", targetTreeSha);
metadata.Add("HeadTreeSha", sourceTreeSha);
@@ -150,6 +166,7 @@ public void PerformDiff(string sourceTreeSha, string targetTreeSha)
public void ParseDiffFile(string filename)
{
+ this.EnsureSingleUse();
using (ITracer activity = this.tracer.StartActivity("PerformDiff", EventLevel.Informational))
{
using (StreamReader file = new StreamReader(File.OpenRead(filename)))
@@ -170,22 +187,33 @@ private void FlushStagedQueues()
{
HashSet deletedDirectories =
new HashSet(
- this.stagedDirectoryOperations
+ this.stagedDirectoryOperations.Values
.Where(d => d.Operation == DiffTreeResult.Operations.Delete)
.Select(d => d.TargetPath.TrimEnd(Path.DirectorySeparatorChar)),
GVFSPlatform.Instance.Constants.PathComparer);
- foreach (DiffTreeResult result in this.stagedDirectoryOperations)
+ // Also include directories that were deleted as part of case-only renames.
+ // These were replaced by Adds in stagedDirectoryOperations but their children's
+ // file deletes should still be filtered out (the parent rename handles them).
+ deletedDirectories.UnionWith(this.directoriesReplacedByCaseRename);
+
+ foreach (DiffTreeResult result in this.stagedDirectoryOperations.Values)
{
string parentPath = Path.GetDirectoryName(result.TargetPath.TrimEnd(Path.DirectorySeparatorChar));
if (deletedDirectories.Contains(parentPath))
{
if (result.Operation != DiffTreeResult.Operations.Delete)
{
- EventMetadata metadata = new EventMetadata();
- metadata.Add(nameof(result.TargetPath), result.TargetPath);
- metadata.Add(TracingConstants.MessageKey.WarningMessage, "An operation is intended to go inside of a deleted folder");
- activity.RelatedError("InvalidOperation", metadata);
+ // For case renames, child directory Adds inside a case-renamed parent
+ // are expected. The parent rename will handle moving the children.
+ // Only warn if the parent is truly deleted (not case-renamed).
+ if (!this.directoriesReplacedByCaseRename.Contains(parentPath))
+ {
+ EventMetadata metadata = new EventMetadata();
+ metadata.Add(nameof(result.TargetPath), result.TargetPath);
+ metadata.Add(TracingConstants.MessageKey.WarningMessage, "An operation is intended to go inside of a deleted folder");
+ activity.RelatedError("InvalidOperation", metadata);
+ }
}
}
else
@@ -194,7 +222,7 @@ private void FlushStagedQueues()
}
}
- foreach (string filePath in this.stagedFileDeletes)
+ foreach (string filePath in this.stagedFileDeletes.Values)
{
string parentPath = Path.GetDirectoryName(filePath);
if (!deletedDirectories.Contains(parentPath))
@@ -222,16 +250,16 @@ private void EnqueueOperationsFromLsTreeLine(ITracer activity, string line)
if (result.TargetIsDirectory)
{
- if (!this.stagedDirectoryOperations.Add(result))
+ if (!this.stagedDirectoryOperations.TryAdd(result.TargetPath, result))
{
EventMetadata metadata = new EventMetadata();
metadata.Add(nameof(result.TargetPath), result.TargetPath);
metadata.Add(TracingConstants.MessageKey.WarningMessage, "File exists in tree with two different cases. Taking the last one.");
this.tracer.RelatedEvent(EventLevel.Warning, "CaseConflict", metadata);
- // Since we match only on filename, re-adding is the easiest way to update the set.
- this.stagedDirectoryOperations.Remove(result);
- this.stagedDirectoryOperations.Add(result);
+ // Two entries in the same tree differ only in case. Keep the
+ // last one parsed, matching the historical HashSet behavior.
+ this.stagedDirectoryOperations[result.TargetPath] = result;
}
}
else
@@ -274,27 +302,52 @@ private void EnqueueOperationsFromDiffTreeLine(ITracer activity, string line)
switch (result.Operation)
{
case DiffTreeResult.Operations.Delete:
- if (!this.stagedDirectoryOperations.Add(result))
+ if (!this.stagedDirectoryOperations.TryAdd(result.TargetPath, result))
{
- EventMetadata metadata = new EventMetadata();
- metadata.Add(nameof(result.TargetPath), result.TargetPath);
- metadata.Add(TracingConstants.MessageKey.WarningMessage, "A case change was attempted. It will not be reflected in the working directory.");
- activity.RelatedEvent(EventLevel.Warning, "CaseConflict", metadata);
+ // A directory with the same (case-insensitive) path was already
+ // staged as an Add. This is a case-only rename where diff-tree
+ // emitted the Add before the Delete. Either emit order is possible
+ // because git diff-tree compares tree entries by byte order, so
+ // whichever casing sorts lower appears first.
+ //
+ // Annotate the staged Add with the old-cased path so CheckoutStage
+ // can perform the rename. Keep the Add — never the Delete — to
+ // avoid deleting a folder out from under ourselves.
+ DiffTreeResult existingOp = this.stagedDirectoryOperations[result.TargetPath];
+ if (!existingOp.TargetPath.Equals(result.TargetPath, StringComparison.Ordinal))
+ {
+ existingOp.SourcePath = result.TargetPath;
+ this.directoriesReplacedByCaseRename.Add(result.TargetPath.TrimEnd(Path.DirectorySeparatorChar));
+ TraceCaseRename(activity, "Directory", oldPath: result.TargetPath, newPath: existingOp.TargetPath);
+ }
+ else
+ {
+ TraceDuplicate(activity, "Directory", "Delete", result.TargetPath);
+ }
}
break;
case DiffTreeResult.Operations.Add:
case DiffTreeResult.Operations.Modify:
- if (!this.stagedDirectoryOperations.Add(result))
+ if (!this.stagedDirectoryOperations.TryAdd(result.TargetPath, result))
{
- EventMetadata metadata = new EventMetadata();
- metadata.Add(nameof(result.TargetPath), result.TargetPath);
- metadata.Add(TracingConstants.MessageKey.WarningMessage, "A case change was attempted. It will not be reflected in the working directory.");
- activity.RelatedEvent(EventLevel.Warning, "CaseConflict", metadata);
+ // A directory with the same path (case-insensitive) was already staged.
+ // This is a case-only rename: the Delete was staged first, now the Add arrives.
+ DiffTreeResult existingOp = this.stagedDirectoryOperations[result.TargetPath];
+ if (!existingOp.TargetPath.Equals(result.TargetPath, StringComparison.Ordinal))
+ {
+ // Case-only rename: store the old-cased path so CheckoutStage can rename the directory
+ result.SourcePath = existingOp.TargetPath;
+ this.directoriesReplacedByCaseRename.Add(existingOp.TargetPath.TrimEnd(Path.DirectorySeparatorChar));
+ TraceCaseRename(activity, "Directory", oldPath: existingOp.TargetPath, newPath: result.TargetPath);
+ }
+ else
+ {
+ TraceDuplicate(activity, "Directory", result.Operation.ToString(), result.TargetPath);
+ }
// Replace the delete with the add to make sure we don't delete a folder from under ourselves
- this.stagedDirectoryOperations.Remove(result);
- this.stagedDirectoryOperations.Add(result);
+ this.stagedDirectoryOperations[result.TargetPath] = result;
}
break;
@@ -357,17 +410,24 @@ private bool ShouldIncludeResult(DiffTreeResult blobAdd)
private void EnqueueFileDeleteOperation(ITracer activity, string targetPath)
{
- if (this.filesAdded.Contains(targetPath))
+ // Use case-sensitive check: if the exact same path (same casing) was already added,
+ // this is a true duplicate, not a case rename. Skip it.
+ // But if it matches case-insensitively only, this is a case rename — allow the delete through
+ // so the old-cased file is removed before the new-cased file is written.
+ if (this.filesAdded.TryGetValue(targetPath, out string existingAddedPath))
{
- EventMetadata metadata = new EventMetadata();
- metadata.Add(nameof(targetPath), targetPath);
- metadata.Add(TracingConstants.MessageKey.WarningMessage, "A case change was attempted. It will not be reflected in the working directory.");
- activity.RelatedEvent(EventLevel.Warning, "CaseConflict", metadata);
+ if (existingAddedPath.Equals(targetPath, StringComparison.Ordinal))
+ {
+ TraceDuplicate(activity, "File", "Delete", targetPath);
+ return;
+ }
- return;
+ TraceCaseRename(activity, "File", oldPath: targetPath, newPath: existingAddedPath);
}
- this.stagedFileDeletes.Add(targetPath);
+ // Either no prior add, or a case-only difference: allow the delete to be
+ // staged so the old casing is removed from disk before the new add lands.
+ this.stagedFileDeletes.TryAdd(targetPath, targetPath);
}
///
@@ -377,7 +437,7 @@ private void EnqueueFileAddOperation(ITracer activity, DiffTreeResult operation)
{
// Each filepath should be unique according to GVFSPlatform.Instance.Constants.PathComparer.
// If there are duplicates, only the last parsed one should remain.
- if (!this.filesAdded.Add(operation.TargetPath))
+ if (!this.filesAdded.TryAdd(operation.TargetPath, operation.TargetPath))
{
foreach (KeyValuePair> kvp in this.FileAddOperations)
{
@@ -389,12 +449,21 @@ private void EnqueueFileAddOperation(ITracer activity, DiffTreeResult operation)
}
}
- if (this.stagedFileDeletes.Remove(operation.TargetPath))
+ // If a delete is already staged for the same path under the case-insensitive
+ // comparer, decide whether this is a true duplicate (same casing → drop the
+ // delete) or a case-only rename (different casing → keep the delete so the
+ // old casing is removed from disk before the new add lands).
+ if (this.stagedFileDeletes.TryGetValue(operation.TargetPath, out string existingDeletePath))
{
- EventMetadata metadata = new EventMetadata();
- metadata.Add(nameof(operation.TargetPath), operation.TargetPath);
- metadata.Add(TracingConstants.MessageKey.WarningMessage, "A case change was attempted. It will not be reflected in the working directory.");
- activity.RelatedEvent(EventLevel.Warning, "CaseConflict", metadata);
+ if (existingDeletePath.Equals(operation.TargetPath, StringComparison.Ordinal))
+ {
+ TraceDuplicate(activity, "File", "Add", operation.TargetPath);
+ this.stagedFileDeletes.Remove(operation.TargetPath);
+ }
+ else
+ {
+ TraceCaseRename(activity, "File", oldPath: existingDeletePath, newPath: operation.TargetPath);
+ }
}
this.FileAddOperations.AddOrUpdate(
@@ -409,31 +478,42 @@ private void EnqueueFileAddOperation(ITracer activity, DiffTreeResult operation)
this.RequiredBlobs.Add(operation.TargetSha);
}
- private class DiffTreeByNameComparer : IEqualityComparer
+ private static void TraceCaseRename(ITracer activity, string kind, string oldPath, string newPath)
{
- public bool Equals(DiffTreeResult x, DiffTreeResult y)
- {
- if (x.TargetPath != null)
- {
- if (y.TargetPath != null)
- {
- return x.TargetPath.Equals(y.TargetPath, GVFSPlatform.Instance.Constants.PathComparison);
- }
+ EventMetadata metadata = new EventMetadata();
+ metadata.Add("Kind", kind);
+ metadata.Add("OldPath", oldPath);
+ metadata.Add("NewPath", newPath);
+ activity.RelatedEvent(EventLevel.Informational, "CaseRename", metadata);
+ }
- return false;
- }
- else
- {
- // both null means they're equal
- return y.TargetPath == null;
- }
- }
+ private static void TraceDuplicate(ITracer activity, string kind, string operation, string targetPath)
+ {
+ EventMetadata metadata = new EventMetadata();
+ metadata.Add("Kind", kind);
+ metadata.Add("Operation", operation);
+ metadata.Add(nameof(targetPath), targetPath);
+ metadata.Add(TracingConstants.MessageKey.WarningMessage, "Duplicate diff entry for the same path; later occurrence collapsed into earlier.");
+ activity.RelatedEvent(EventLevel.Warning, "DuplicateDiffEntry", metadata);
+ }
- public int GetHashCode(DiffTreeResult obj)
+ private void EnsureSingleUse()
+ {
+ // The output collections — DirectoryOperations, FileDeleteOperations,
+ // FileAddOperations, RequiredBlobs — are populated incrementally and
+ // RequiredBlobs.CompleteAdding() is called at the end of FlushStagedQueues.
+ // A second call would attempt to add to a completed BlockingCollection
+ // and throw deep in the parsing path, leaving partial output. The class
+ // is therefore intended to be single-use; instantiate a new DiffHelper
+ // for each diff.
+ if (this.diffPerformed)
{
- return obj.TargetPath != null ?
- GVFSPlatform.Instance.Constants.PathComparer.GetHashCode(obj.TargetPath) : 0;
+ throw new InvalidOperationException(
+ $"{nameof(DiffHelper)} has already produced a diff and cannot be reused. Construct a new instance.");
}
+
+ this.diffPerformed = true;
}
+
}
}
diff --git a/GVFS/GVFS.Common/ReturnCode.cs b/GVFS/GVFS.Common/ReturnCode.cs
index 5243cb2f5..09396a861 100644
--- a/GVFS/GVFS.Common/ReturnCode.cs
+++ b/GVFS/GVFS.Common/ReturnCode.cs
@@ -11,5 +11,6 @@ public enum ReturnCode
UnableToRegisterForOfflineIO = 6,
DehydrateFolderFailures = 7,
MountAlreadyRunning = 8,
+ AuthenticationError = 9,
}
}
diff --git a/GVFS/GVFS.Common/Tracing/BufferingTelemetryListener.cs b/GVFS/GVFS.Common/Tracing/BufferingTelemetryListener.cs
new file mode 100644
index 000000000..58af567ee
--- /dev/null
+++ b/GVFS/GVFS.Common/Tracing/BufferingTelemetryListener.cs
@@ -0,0 +1,83 @@
+using System.Collections.Concurrent;
+
+namespace GVFS.Common.Tracing
+{
+ ///
+ /// An EventListener that buffers telemetry messages in memory. After
+ /// a real listener is attached via , buffered
+ /// messages are replayed and this listener becomes a no-op.
+ ///
+ public class BufferingTelemetryListener : EventListener
+ {
+ public const int DefaultMaxBufferedMessages = 1000;
+
+ private ConcurrentQueue buffer = new ConcurrentQueue();
+ private readonly int maxBufferedMessages;
+ private volatile bool stopped;
+
+ public BufferingTelemetryListener(int maxBufferedMessages = DefaultMaxBufferedMessages)
+ : base(EventLevel.Verbose, Keywords.Telemetry, eventSink: null)
+ {
+ this.maxBufferedMessages = maxBufferedMessages;
+ }
+
+ ///
+ /// Number of messages currently buffered.
+ ///
+ public int BufferedCount => this.buffer?.Count ?? 0;
+
+ ///
+ /// Whether this listener has been stopped (replay completed).
+ ///
+ public bool IsStopped => this.stopped;
+
+ ///
+ /// Replays all buffered messages to and
+ /// stops further buffering. This listener remains in the tracer's
+ /// listener list but becomes a no-op. Safe to call multiple times;
+ /// only the first call replays.
+ ///
+ /// Number of messages replayed.
+ public int ReplayAndStop(EventListener target)
+ {
+ if (this.stopped)
+ {
+ return 0;
+ }
+
+ this.stopped = true;
+ ConcurrentQueue queue = this.buffer;
+ this.buffer = null;
+
+ int count = 0;
+ if (queue != null)
+ {
+ while (queue.TryDequeue(out TraceEventMessage message))
+ {
+ target.RecordMessage(message);
+ count++;
+ }
+ }
+
+ return count;
+ }
+
+ protected override void RecordMessageInternal(TraceEventMessage message)
+ {
+ if (this.stopped)
+ {
+ return;
+ }
+
+ // Soft cap: under high concurrency, a few messages may exceed
+ // maxBufferedMessages because Count and Enqueue are not atomic.
+ // This is acceptable — the cap prevents unbounded growth, and
+ // a small overshoot is harmless.
+ ConcurrentQueue queue = this.buffer;
+ if (queue != null && queue.Count < this.maxBufferedMessages)
+ {
+ queue.Enqueue(message);
+ }
+ }
+ }
+}
diff --git a/GVFS/GVFS.Common/Tracing/DeferredTelemetryAttacher.cs b/GVFS/GVFS.Common/Tracing/DeferredTelemetryAttacher.cs
new file mode 100644
index 000000000..1dc68a072
--- /dev/null
+++ b/GVFS/GVFS.Common/Tracing/DeferredTelemetryAttacher.cs
@@ -0,0 +1,208 @@
+using System;
+using System.Threading;
+
+namespace GVFS.Common.Tracing
+{
+ ///
+ /// Manages deferred telemetry pipe attachment for processes that cannot
+ /// read the pipe config at startup (e.g. GVFS.Service running as SYSTEM,
+ /// or any process started before the telemetry collector is installed).
+ ///
+ /// Adds a to the tracer at
+ /// construction time, then periodically retries creating a real
+ /// . On success, buffered
+ /// messages are replayed and the retry timer stops.
+ ///
+ /// Callers can also trigger an explicit attach attempt via
+ /// — e.g. on session logon when the
+ /// user's HOME is available.
+ ///
+ /// Designed for reuse by both GVFS.Service and GVFS.Mount.
+ ///
+ public class DeferredTelemetryAttacher : IDisposable
+ {
+ private readonly JsonTracer tracer;
+ private readonly BufferingTelemetryListener buffer;
+ private readonly string providerName;
+ private readonly string enlistmentId;
+ private readonly string mountId;
+ private readonly Lock attachLock = new Lock();
+
+ private Timer retryTimer;
+ private string retryGitBinRoot;
+ private int retryCount;
+ private bool attached;
+ private bool disposed;
+
+ public DeferredTelemetryAttacher(
+ JsonTracer tracer,
+ string providerName,
+ string enlistmentId,
+ string mountId)
+ {
+ this.tracer = tracer;
+ this.providerName = providerName;
+ this.enlistmentId = enlistmentId;
+ this.mountId = mountId;
+ this.buffer = new BufferingTelemetryListener();
+ tracer.AddEventListener(this.buffer);
+ }
+
+ public bool IsAttached
+ {
+ get
+ {
+ lock (this.attachLock)
+ {
+ return this.attached;
+ }
+ }
+ }
+
+ ///
+ /// Starts a background retry timer that periodically calls
+ /// with the given gitBinRoot. Uses
+ /// exponential backoff: 10s, 30s, 1m, then 5m steady state.
+ ///
+ public void StartRetryTimer(string gitBinRoot)
+ {
+ lock (this.attachLock)
+ {
+ if (this.attached || this.disposed || this.retryTimer != null)
+ {
+ return;
+ }
+
+ this.retryGitBinRoot = gitBinRoot;
+ this.retryCount = 0;
+ this.retryTimer = new Timer(
+ this.OnRetryTimer,
+ null,
+ GetRetryInterval(0),
+ Timeout.Infinite);
+ }
+ }
+
+ ///
+ /// Attempts to create and attach a TelemetryDaemonEventListener.
+ /// Call this when environment conditions change (e.g. user session
+ /// becomes available). Replays buffered messages on success.
+ /// Safe to call multiple times — no-ops after first successful attach.
+ ///
+ /// Path to git binary.
+ ///
+ /// If non-null, reads this file with git config --file instead
+ /// of --global. Use this when the caller needs to read another
+ /// user's .gitconfig without mutating the process-wide HOME variable.
+ ///
+ /// true if attached (now or previously).
+ public bool TryAttach(string gitBinRoot, string globalConfigPath = null)
+ {
+ lock (this.attachLock)
+ {
+ if (this.attached || this.tracer.HasTelemetryDaemonListener)
+ {
+ return true;
+ }
+
+ if (string.IsNullOrEmpty(gitBinRoot))
+ {
+ return false;
+ }
+
+ TelemetryDaemonEventListener daemonListener;
+ try
+ {
+ daemonListener = TelemetryDaemonEventListener.CreateIfEnabled(
+ gitBinRoot,
+ this.providerName,
+ this.enlistmentId,
+ this.mountId,
+ this.tracer,
+ globalConfigPath);
+ }
+ catch (Exception)
+ {
+ return false;
+ }
+
+ if (daemonListener == null)
+ {
+ return false;
+ }
+
+ this.tracer.AddEventListener(daemonListener);
+ int replayed = this.buffer.ReplayAndStop(daemonListener);
+ this.StopRetryTimer();
+ this.attached = true;
+
+ this.tracer.RelatedInfo(
+ "DeferredTelemetryAttacher: Attached, replayed {0} buffered messages",
+ replayed);
+
+ return true;
+ }
+ }
+
+ public void Dispose()
+ {
+ lock (this.attachLock)
+ {
+ if (this.disposed)
+ {
+ return;
+ }
+
+ this.disposed = true;
+ this.StopRetryTimer();
+ }
+ }
+
+ internal static int GetRetryInterval(int retryCount)
+ {
+ return retryCount switch
+ {
+ 0 => 10_000, // 10 seconds
+ 1 => 30_000, // 30 seconds
+ 2 => 60_000, // 1 minute
+ _ => 300_000, // 5 minutes
+ };
+ }
+
+ private void StopRetryTimer()
+ {
+ // Must be called while holding attachLock
+ if (this.retryTimer != null)
+ {
+ this.retryTimer.Dispose();
+ this.retryTimer = null;
+ }
+ }
+
+ private void OnRetryTimer(object state)
+ {
+ try
+ {
+ bool success = this.TryAttach(this.retryGitBinRoot);
+ if (!success)
+ {
+ lock (this.attachLock)
+ {
+ if (this.retryTimer != null && !this.disposed)
+ {
+ this.retryCount++;
+ this.retryTimer.Change(
+ GetRetryInterval(this.retryCount),
+ Timeout.Infinite);
+ }
+ }
+ }
+ }
+ catch (Exception)
+ {
+ // Swallow — timer will not reschedule, but the explicit
+ // TryAttach path (e.g. on SessionLogon) can still succeed.
+ }
+ }
+ }
+}
diff --git a/GVFS/GVFS.Common/Tracing/JsonTracer.cs b/GVFS/GVFS.Common/Tracing/JsonTracer.cs
index c494bd012..cc74cb71d 100644
--- a/GVFS/GVFS.Common/Tracing/JsonTracer.cs
+++ b/GVFS/GVFS.Common/Tracing/JsonTracer.cs
@@ -81,6 +81,14 @@ public bool HasLogFileEventListener
}
}
+ public bool HasTelemetryDaemonListener
+ {
+ get
+ {
+ return this.listeners.Any(listener => listener is TelemetryDaemonEventListener);
+ }
+ }
+
public void SetGitCommandSessionId(string sessionId)
{
TelemetryDaemonEventListener daemonListener = this.listeners.FirstOrDefault(x => x is TelemetryDaemonEventListener) as TelemetryDaemonEventListener;
diff --git a/GVFS/GVFS.Common/Tracing/PrettyConsoleEventListener.cs b/GVFS/GVFS.Common/Tracing/PrettyConsoleEventListener.cs
index 5999d97ee..1cdefe681 100644
--- a/GVFS/GVFS.Common/Tracing/PrettyConsoleEventListener.cs
+++ b/GVFS/GVFS.Common/Tracing/PrettyConsoleEventListener.cs
@@ -1,4 +1,5 @@
using System;
+using System.Threading;
namespace GVFS.Common.Tracing
{
@@ -9,7 +10,7 @@ namespace GVFS.Common.Tracing
///
public class PrettyConsoleEventListener : EventListener
{
- private static object consoleLock = new object();
+ private static Lock consoleLock = new Lock();
public PrettyConsoleEventListener(EventLevel maxVerbosity, Keywords keywordFilter, IEventListenerEventSink eventSink)
: base(maxVerbosity, keywordFilter, eventSink)
diff --git a/GVFS/GVFS.Common/Tracing/TelemetryDaemonEventListener.cs b/GVFS/GVFS.Common/Tracing/TelemetryDaemonEventListener.cs
index 35e517a42..17dc588bb 100644
--- a/GVFS/GVFS.Common/Tracing/TelemetryDaemonEventListener.cs
+++ b/GVFS/GVFS.Common/Tracing/TelemetryDaemonEventListener.cs
@@ -37,9 +37,22 @@ private TelemetryDaemonEventListener(
public string GitCommandSessionId { get; set; }
public static TelemetryDaemonEventListener CreateIfEnabled(string gitBinRoot, string providerName, string enlistmentId, string mountId, IEventListenerEventSink eventSink)
+ {
+ return CreateIfEnabled(gitBinRoot, providerName, enlistmentId, mountId, eventSink, globalConfigPath: null);
+ }
+
+ ///
+ /// Creates a TelemetryDaemonEventListener if the telemetry pipe config
+ /// is set. When is provided, reads
+ /// that file directly instead of using git config --global.
+ /// This avoids mutating the process-wide HOME environment variable
+ /// when the caller needs to read another user's config (e.g.
+ /// GVFS.Service reading the logged-on user's .gitconfig).
+ ///
+ public static TelemetryDaemonEventListener CreateIfEnabled(string gitBinRoot, string providerName, string enlistmentId, string mountId, IEventListenerEventSink eventSink, string globalConfigPath)
{
// This listener is disabled unless the user specifies the proper git config setting.
- string telemetryPipe = GetConfigValue(gitBinRoot, GVFSConstants.GitConfig.GVFSTelemetryPipe);
+ string telemetryPipe = GetConfigValue(gitBinRoot, GVFSConstants.GitConfig.GVFSTelemetryPipe, globalConfigPath);
if (!string.IsNullOrEmpty(telemetryPipe))
{
return new TelemetryDaemonEventListener(providerName, enlistmentId, mountId, telemetryPipe, eventSink);
@@ -90,7 +103,7 @@ protected override void RecordMessageInternal(TraceEventMessage message)
}
}
- private static string GetConfigValue(string gitBinRoot, string configKey)
+ private static string GetConfigValue(string gitBinRoot, string configKey, string globalConfigPath = null)
{
string value = string.Empty;
string error;
@@ -98,7 +111,15 @@ private static string GetConfigValue(string gitBinRoot, string configKey)
GitProcess.ConfigResult result = GitProcess.GetFromSystemConfig(gitBinRoot, configKey);
if (!result.TryParseAsString(out value, out error, defaultValue: string.Empty) || string.IsNullOrWhiteSpace(value))
{
- result = GitProcess.GetFromGlobalConfig(gitBinRoot, configKey);
+ if (!string.IsNullOrEmpty(globalConfigPath))
+ {
+ result = GitProcess.GetFromFileConfig(gitBinRoot, globalConfigPath, configKey);
+ }
+ else
+ {
+ result = GitProcess.GetFromGlobalConfig(gitBinRoot, configKey);
+ }
+
result.TryParseAsString(out value, out error, defaultValue: string.Empty);
}
diff --git a/GVFS/GVFS.FunctionalTests/Tests/FastFetchTests.cs b/GVFS/GVFS.FunctionalTests/Tests/FastFetchTests.cs
index 2a0989327..ad8db56cb 100644
--- a/GVFS/GVFS.FunctionalTests/Tests/FastFetchTests.cs
+++ b/GVFS/GVFS.FunctionalTests/Tests/FastFetchTests.cs
@@ -320,9 +320,9 @@ public void SuccessfullyChecksOutCaseChanges()
try
{
- // Ignore case differences on case-insensitive filesystems
+ // Verify FastFetch produces correct casing matching git checkout, including on case-insensitive filesystems
this.fastFetchRepoRoot.ShouldBeADirectory(FileSystemRunner.DefaultRunner)
- .WithDeepStructure(FileSystemRunner.DefaultRunner, this.fastFetchControlRoot, ignoreCase: !FileSystemHelpers.CaseSensitiveFileSystem);
+ .WithDeepStructure(FileSystemRunner.DefaultRunner, this.fastFetchControlRoot, ignoreCase: false);
}
finally
{
diff --git a/GVFS/GVFS.Hooks/GVFS.Hooks.csproj b/GVFS/GVFS.Hooks/GVFS.Hooks.csproj
index b45fbbeef..c960ac430 100644
--- a/GVFS/GVFS.Hooks/GVFS.Hooks.csproj
+++ b/GVFS/GVFS.Hooks/GVFS.Hooks.csproj
@@ -5,10 +5,6 @@
true
-
-
-
-