diff --git a/.azure-pipelines/official-release-nuget.config b/.azure-pipelines/official-release-nuget.config
new file mode 100644
index 000000000..4df1aaa4c
--- /dev/null
+++ b/.azure-pipelines/official-release-nuget.config
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
diff --git a/.azure-pipelines/release.yml b/.azure-pipelines/release.yml
index 84b9a2b80..9433cb2f5 100644
--- a/.azure-pipelines/release.yml
+++ b/.azure-pipelines/release.yml
@@ -111,6 +111,9 @@ extends:
inputs:
versionSpec: '6.x'
+ - task: NuGetAuthenticate@1
+ displayName: 'Authenticate to internal NuGet feed (for Microsoft.Build.Vcpkg)'
+
- task: PowerShell@2
displayName: 'Install VS C++ workload (NativeAOT prerequisite)'
inputs:
@@ -121,6 +124,37 @@ extends:
inputs:
filePath: $(Build.SourcesDirectory)\.azure-pipelines\scripts\enable-projfs.ps1
+ # Download the Microsoft.Build.Vcpkg NuGet package out-of-band so we
+ # can hand the build a path to TerrapinRetrievalTool.exe via
+ # -p:TerrapinRetrievalToolPath. The package is pulled from an
+ # internal NuGet feed (see .azure-pipelines/official-release-nuget.config).
+ # Downloading it this way -- rather than as an msbuild Sdk import --
+ # keeps the internal feed out of the root nuget.config that
+ # external contributors and the public GitHub Actions workflow see.
+ - task: NuGetCommand@2
+ displayName: 'Download Microsoft.Build.Vcpkg package (Terrapin retrieval tool)'
+ inputs:
+ command: custom
+ arguments: 'install Microsoft.Build.Vcpkg -Version 2026.5.25.434-aa40adda53 -ConfigFile $(Build.SourcesDirectory)\.azure-pipelines\official-release-nuget.config -OutputDirectory $(Agent.TempDirectory)\nuget-internal -ExcludeVersion -DirectDownload -NonInteractive'
+
+ # Restore vcpkg native dependencies through the Terrapin asset
+ # cache (the release pipeline's build agents have x-block-origin
+ # enforced and cannot download from the public internet). Runs the
+ # _RestoreVcpkgDependencies MSBuild target with
+ # UseTerrapinAssetCache=true and TerrapinRetrievalToolPath pointing
+ # at the binary extracted by the previous step. vcpkg downloads
+ # then route through https://vcpkg.storage.devpackages.microsoft.io.
+ # Build.bat's own vcpkg install step then skips because the libs
+ # are already present.
+ - script: |
+ dotnet build "$(Build.SourcesDirectory)\GVFS\GVFS.Common\GVFS.Common.csproj" ^
+ /t:_RestoreVcpkgDependencies ^
+ -c $(BuildConfiguration) ^
+ -p:UseTerrapinAssetCache=true ^
+ -p:TerrapinRetrievalToolPath=$(Agent.TempDirectory)\nuget-internal\Microsoft.Build.Vcpkg\trt\TerrapinRetrievalTool.exe ^
+ -v:detailed
+ displayName: 'Restore vcpkg native libraries (Terrapin cache)'
+
- script: |
$(Build.SourcesDirectory)\scripts\Build.bat ^
$(BuildConfiguration) ^
diff --git a/Directory.Build.props b/Directory.Build.props
index 89953442c..b7752b80f 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -18,6 +18,25 @@
but our .NET projects do.
-->
true
+
+
+ false
+
+
+ false
diff --git a/Directory.Build.targets b/Directory.Build.targets
index d04b30b4f..f78140643 100644
--- a/Directory.Build.targets
+++ b/Directory.Build.targets
@@ -32,6 +32,22 @@
<_VcpkgManifestFile>$(RepoSrcPath)vcpkg.json
<_VcpkgConfigFile>$(RepoSrcPath)vcpkg-configuration.json
<_VcpkgStampFile>$(RepoOutPath)vcpkg_installed\.msbuildstamp
+
+
+ <_VcpkgAssetSources Condition="'$(UseTerrapinAssetCache)' == 'true'">"--x-asset-sources=x-script,$(TerrapinRetrievalToolPath) -b https://vcpkg.storage.devpackages.microsoft.io/artifacts/ -a $(IsLocalBuild) -p {url} -s {sha512} -d {dst};x-block-origin"
@@ -70,13 +86,25 @@
-
+
+
+
-
+ Text="vcpkg install completed but git2.dll (dynamic) is missing. Check vcpkg output above for errors." />
+
diff --git a/GVFS/GVFS.Common/GVFSLock.cs b/GVFS/GVFS.Common/GVFSLock.cs
index 5bfaa5976..c8cac11ff 100644
--- a/GVFS/GVFS.Common/GVFSLock.cs
+++ b/GVFS/GVFS.Common/GVFSLock.cs
@@ -41,6 +41,20 @@ public bool TryAcquireLockForExternalRequestor(
existingExternalHolder = null;
+ // Capture the requestor's process start time so we can later distinguish the
+ // genuine holder from an unrelated process that happens to be reusing the same
+ // PID after the holder exits. If we cannot read the start time (e.g. permission
+ // failure on OpenProcess for a different-integrity caller) we still accept the
+ // lock and fall back to the legacy PID-only orphan check; record the fallback in
+ // telemetry so we can spot if it becomes common.
+ long? requestorStartTime = GVFSPlatform.Instance.TryGetActiveProcessStartTime(requestor.PID, out long startTime)
+ ? startTime
+ : (long?)null;
+ if (requestorStartTime == null)
+ {
+ metadata.Add("StartTimeUnavailable", true);
+ }
+
try
{
lock (this.acquisitionLock)
@@ -65,7 +79,7 @@ public bool TryAcquireLockForExternalRequestor(
metadata.Add("Result", "Accepted");
eventLevel = EventLevel.Informational;
- this.currentLockHolder.AcquireForExternalRequestor(requestor);
+ this.currentLockHolder.AcquireForExternalRequestor(requestor, requestorStartTime);
this.Stats = new ActiveGitCommandStats();
return true;
@@ -190,12 +204,14 @@ private bool IsLockAvailable(bool checkExternalHolderOnly, out NamedPipeMessages
}
bool externalHolderTerminatedWithoutReleasingLock;
+ string terminationReason;
existingExternalHolder = this.currentLockHolder.GetExternalHolder(
- out externalHolderTerminatedWithoutReleasingLock);
+ out externalHolderTerminatedWithoutReleasingLock,
+ out terminationReason);
if (externalHolderTerminatedWithoutReleasingLock)
{
- this.ReleaseLockForTerminatedProcess(existingExternalHolder.PID);
+ this.ReleaseLockForTerminatedProcess(existingExternalHolder.PID, terminationReason);
this.tracer.SetGitCommandSessionId(string.Empty);
existingExternalHolder = null;
}
@@ -204,11 +220,11 @@ private bool IsLockAvailable(bool checkExternalHolderOnly, out NamedPipeMessages
}
}
- private bool ReleaseExternalLock(int pid, string eventName)
+ private bool ReleaseExternalLock(int pid, string eventName, EventMetadata extraMetadata = null)
{
lock (this.acquisitionLock)
{
- EventMetadata metadata = new EventMetadata();
+ EventMetadata metadata = extraMetadata ?? new EventMetadata();
try
{
@@ -251,9 +267,11 @@ private bool ReleaseExternalLock(int pid, string eventName)
}
}
- private void ReleaseLockForTerminatedProcess(int pid)
+ private void ReleaseLockForTerminatedProcess(int pid, string terminationReason)
{
- this.ReleaseExternalLock(pid, "ExternalLockHolderExited");
+ EventMetadata metadata = new EventMetadata();
+ metadata.Add("ExternalHolderTerminationReason", terminationReason ?? "Unknown");
+ this.ReleaseExternalLock(pid, "ExternalLockHolderExited", metadata);
}
// The lock release event is a convenient place to record stats about things that happened while a git command was running,
@@ -383,6 +401,7 @@ public void AddStatsToTelemetry(EventMetadata metadata)
private class LockHolder
{
private NamedPipeMessages.LockData externalLockHolder;
+ private long? externalLockHolderStartTime;
public bool IsFree
{
@@ -404,7 +423,7 @@ public void AcquireForGVFS()
this.IsGVFS = true;
}
- public void AcquireForExternalRequestor(NamedPipeMessages.LockData externalLockHolder)
+ public void AcquireForExternalRequestor(NamedPipeMessages.LockData externalLockHolder, long? startTime)
{
if (this.IsGVFS ||
this.externalLockHolder != null)
@@ -413,12 +432,14 @@ public void AcquireForExternalRequestor(NamedPipeMessages.LockData externalLockH
}
this.externalLockHolder = externalLockHolder;
+ this.externalLockHolderStartTime = startTime;
}
public void Release()
{
this.IsGVFS = false;
this.externalLockHolder = null;
+ this.externalLockHolderStartTime = null;
}
public NamedPipeMessages.LockData GetExternalHolder()
@@ -426,14 +447,44 @@ public NamedPipeMessages.LockData GetExternalHolder()
return this.externalLockHolder;
}
- public NamedPipeMessages.LockData GetExternalHolder(out bool externalHolderTerminatedWithoutReleasingLock)
+ public NamedPipeMessages.LockData GetExternalHolder(out bool externalHolderTerminatedWithoutReleasingLock, out string terminationReason)
{
externalHolderTerminatedWithoutReleasingLock = false;
+ terminationReason = null;
if (this.externalLockHolder != null)
{
int pid = this.externalLockHolder.PID;
- externalHolderTerminatedWithoutReleasingLock = !GVFSPlatform.Instance.IsProcessActive(pid);
+
+ if (this.externalLockHolderStartTime is long capturedStartTime)
+ {
+ // Identity check: confirm the same process still owns this PID by comparing
+ // the OS-supplied process start time we captured at acquisition with the
+ // current one. A mismatch means the original holder exited and Windows
+ // recycled the PID to a different process (the bug this code fixes).
+ if (!GVFSPlatform.Instance.TryGetActiveProcessStartTime(pid, out long currentStartTime))
+ {
+ externalHolderTerminatedWithoutReleasingLock = true;
+ terminationReason = "ProcessNotActive";
+ }
+ else if (currentStartTime != capturedStartTime)
+ {
+ externalHolderTerminatedWithoutReleasingLock = true;
+ terminationReason = "PidRecycled";
+ }
+ }
+ else
+ {
+ // Fallback for the rare case where we could not capture a start time at
+ // acquisition time (e.g. cross-integrity OpenProcess denial). Use the
+ // legacy PID-only liveness check, which is vulnerable to PID recycling
+ // but matches pre-fix behavior.
+ if (!GVFSPlatform.Instance.IsProcessActive(pid))
+ {
+ externalHolderTerminatedWithoutReleasingLock = true;
+ terminationReason = "ProcessNotActive";
+ }
+ }
}
return this.externalLockHolder;
diff --git a/GVFS/GVFS.Common/GVFSPlatform.cs b/GVFS/GVFS.Common/GVFSPlatform.cs
index 579e94955..d5132066b 100644
--- a/GVFS/GVFS.Common/GVFSPlatform.cs
+++ b/GVFS/GVFS.Common/GVFSPlatform.cs
@@ -66,6 +66,19 @@ public static void Register(GVFSPlatform platform)
public abstract void PrepareProcessToRunInBackground();
public abstract bool IsProcessActive(int processId);
+
+ ///
+ /// Returns true and writes an opaque, OS-supplied process-identity token (e.g. process
+ /// creation time on Windows) when the process with the given PID is currently active.
+ /// The token has no meaning beyond identity comparison: two calls for the same underlying
+ /// process yield equal tokens, and a call after the OS has recycled the PID to a different
+ /// process yields a different token. Returns false (and startTime = 0) if the process
+ /// is no longer running, or if it could not be identified for any reason (e.g. permission
+ /// failure). Callers must treat a false return as "no identity information available" and
+ /// fall back to if they still need a liveness check.
+ ///
+ public abstract bool TryGetActiveProcessStartTime(int processId, out long startTime);
+
public abstract void IsServiceInstalledAndRunning(string name, out bool installed, out bool running);
public abstract string GetNamedPipeName(string enlistmentRoot);
public abstract string GetGVFSServiceNamedPipeName(string serviceName);
diff --git a/GVFS/GVFS.Common/Git/GitProcess.cs b/GVFS/GVFS.Common/Git/GitProcess.cs
index caca4df64..b818fd915 100644
--- a/GVFS/GVFS.Common/Git/GitProcess.cs
+++ b/GVFS/GVFS.Common/Git/GitProcess.cs
@@ -815,16 +815,24 @@ public Result MultiPackIndexRepack(string gitObjectDirectory, string batchSize)
return this.InvokeGitAgainstDotGitFolder($"-c pack.threads=1 -c repack.packKeptObjects=true multi-pack-index repack --object-dir=\"{gitObjectDirectory}\" --batch-size={batchSize} --no-progress");
}
- public Process GetGitProcess(string command, string workingDirectory, string dotGitDirectory, bool useReadObjectHook, bool redirectStandardError, string gitObjectsDirectory, bool usePreCommandHook)
+ public Process GetGitProcess(string command, string workingDirectory, string dotGitDirectory, bool useReadObjectHook, string gitObjectsDirectory, bool usePreCommandHook)
{
ProcessStartInfo processInfo = new ProcessStartInfo(this.gitBinPath);
processInfo.WorkingDirectory = workingDirectory;
processInfo.UseShellExecute = false;
processInfo.RedirectStandardInput = true;
processInfo.RedirectStandardOutput = true;
- processInfo.RedirectStandardError = redirectStandardError;
+ processInfo.RedirectStandardError = true;
processInfo.WindowStyle = ProcessWindowStyle.Hidden;
- processInfo.CreateNoWindow = true;
+
+ // CreateNoWindow=false avoids allocating a hidden conhost.exe per child
+ // process. This is safe because both stdout and stderr are redirected via
+ // pipes, so the child never needs a console for I/O. If a future change
+ // stops redirecting either stream (to forward output to the parent console
+ // instead), CreateNoWindow must be set to true for that case — otherwise
+ // the non-redirected stream inherits the parent's console handle, which
+ // may be absent when running as a service, causing lost output.
+ processInfo.CreateNoWindow = false;
processInfo.StandardOutputEncoding = UTF8NoBOM;
processInfo.StandardErrorEncoding = UTF8NoBOM;
@@ -903,7 +911,7 @@ protected virtual Result InvokeGitImpl(
// From https://msdn.microsoft.com/en-us/library/system.diagnostics.process.standardoutput.aspx
// To avoid deadlocks, use asynchronous read operations on at least one of the streams.
// Do not perform a synchronous read to the end of both redirected streams.
- using (this.executingProcess = this.GetGitProcess(command, workingDirectory, dotGitDirectory, useReadObjectHook, redirectStandardError: true, gitObjectsDirectory: gitObjectsDirectory, usePreCommandHook: usePreCommandHook))
+ using (this.executingProcess = this.GetGitProcess(command, workingDirectory, dotGitDirectory, useReadObjectHook, gitObjectsDirectory: gitObjectsDirectory, usePreCommandHook: usePreCommandHook))
{
StringBuilder output = new StringBuilder();
StringBuilder errors = new StringBuilder();
diff --git a/GVFS/GVFS.Common/NativeMethods.Shared.cs b/GVFS/GVFS.Common/NativeMethods.Shared.cs
index 7a796b15e..43aca72f8 100644
--- a/GVFS/GVFS.Common/NativeMethods.Shared.cs
+++ b/GVFS/GVFS.Common/NativeMethods.Shared.cs
@@ -158,6 +158,18 @@ public static extern SafeFileHandle OpenProcess(
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool GetExitCodeProcess(SafeFileHandle hProcess, out uint lpExitCode);
+ // GetProcessTimes writes four FILETIME values (each two DWORDs / 8 bytes).
+ // We marshal each as `out long` because we only ever compare the raw 64-bit value
+ // (creation time) for identity purposes; we never decode it as a date.
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ public static extern bool GetProcessTimes(
+ SafeFileHandle hProcess,
+ out long lpCreationTime,
+ out long lpExitTime,
+ out long lpKernelTime,
+ out long lpUserTime);
+
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern SafeFileHandle CreateFile(
[In] string lpFileName,
diff --git a/GVFS/GVFS.Common/ProcessHelper.cs b/GVFS/GVFS.Common/ProcessHelper.cs
index a9731d6d5..a67f4159e 100644
--- a/GVFS/GVFS.Common/ProcessHelper.cs
+++ b/GVFS/GVFS.Common/ProcessHelper.cs
@@ -18,7 +18,16 @@ public static ProcessResult Run(string programName, string args, bool redirectOu
processInfo.RedirectStandardOutput = redirectOutput;
processInfo.RedirectStandardError = redirectOutput;
processInfo.WindowStyle = ProcessWindowStyle.Hidden;
- processInfo.CreateNoWindow = redirectOutput;
+
+ // CreateNoWindow=false avoids allocating a hidden conhost.exe per child
+ // process. When redirectOutput is true, I/O goes through pipes so no
+ // console is needed. When redirectOutput is false, the child inherits the
+ // parent's console handles — this works when the parent has a console
+ // (e.g., GVFS.Hooks invoked from a terminal), but output is silently lost
+ // when the parent has no console (e.g., service context). This is
+ // acceptable because CreateNoWindow=true would only send that output to
+ // an invisible hidden console instead.
+ processInfo.CreateNoWindow = false;
processInfo.Arguments = args;
return Run(processInfo);
diff --git a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs
index 5d277d238..1f8501043 100644
--- a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs
+++ b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs
@@ -191,6 +191,83 @@ public void ConcurrentWorktreeAddCommitRemove()
}
}
+ [TestCase]
+ public void WorktreeOutsideEnlistmentTree()
+ {
+ string suffix = Guid.NewGuid().ToString("N").Substring(0, 8);
+ string tempDir = Path.Combine(Path.GetTempPath(), $"gvfs-remote-wt-{suffix}");
+ string worktreePath = Path.Combine(tempDir, "wt");
+ string branchName = $"remote-wt-test-{suffix}";
+
+ try
+ {
+ Directory.CreateDirectory(tempDir);
+
+ // 1. Create worktree outside the enlistment tree
+ ProcessResult addResult = GitHelpers.InvokeGitAgainstGVFSRepo(
+ this.Enlistment.RepoRoot,
+ $"worktree add -b {branchName} \"{worktreePath}\"");
+ addResult.ExitCode.ShouldEqual(0,
+ $"worktree add failed: {addResult.Errors}");
+
+ // 2. Verify GVFS mount is running
+ this.AssertWorktreeMounted(worktreePath, "remote worktree");
+
+ // 3. Verify git status works from the worktree root
+ ProcessResult statusResult = GitHelpers.InvokeGitAgainstGVFSRepo(
+ worktreePath, "status --porcelain");
+ statusResult.ExitCode.ShouldEqual(0,
+ $"git status from worktree root failed: {statusResult.Errors}");
+ statusResult.Output.Trim().ShouldBeEmpty(
+ "Remote worktree should have clean status");
+
+ // 4. Verify projected files are visible
+ File.Exists(Path.Combine(worktreePath, "Readme.md")).ShouldBeTrue(
+ "Readme.md should be projected in remote worktree");
+
+ // 5. Verify git status works from a subdirectory
+ string subDir = Path.Combine(worktreePath, "GVFS");
+ Directory.Exists(subDir).ShouldBeTrue(
+ "Subdirectory GVFS should be projected");
+ ProcessResult subDirStatus = GitHelpers.InvokeGitAgainstGVFSRepo(
+ subDir, "status --porcelain");
+ subDirStatus.ExitCode.ShouldEqual(0,
+ $"git status from subdirectory failed: {subDirStatus.Errors}");
+
+ // 6. Verify commits work
+ File.WriteAllText(
+ Path.Combine(worktreePath, "remote-test.txt"),
+ "created in remote worktree");
+ GitHelpers.InvokeGitAgainstGVFSRepo(worktreePath, "add remote-test.txt")
+ .ExitCode.ShouldEqual(0);
+ GitHelpers.InvokeGitAgainstGVFSRepo(
+ worktreePath, "commit -m \"commit from remote worktree\"")
+ .ExitCode.ShouldEqual(0);
+
+ // 7. Verify commit is visible from primary repo
+ GitHelpers.InvokeGitAgainstGVFSRepo(
+ this.Enlistment.RepoRoot, $"log -1 --format=%s {branchName}")
+ .Output.ShouldContain(expectedSubstrings: new[] { "commit from remote worktree" });
+
+ // 8. Remove worktree
+ ProcessResult removeResult = GitHelpers.InvokeGitAgainstGVFSRepo(
+ this.Enlistment.RepoRoot,
+ $"worktree remove --force \"{worktreePath}\"");
+ removeResult.ExitCode.ShouldEqual(0,
+ $"worktree remove failed: {removeResult.Errors}");
+ Directory.Exists(worktreePath).ShouldBeFalse(
+ "Remote worktree directory should be deleted");
+ }
+ finally
+ {
+ this.ForceCleanupWorktree(worktreePath, branchName);
+ if (Directory.Exists(tempDir))
+ {
+ try { Directory.Delete(tempDir, recursive: true); } catch { }
+ }
+ }
+ }
+
private void InitWorktreeArrays(int count, out string[] paths, out string[] branches)
{
paths = new string[count];
diff --git a/GVFS/GVFS.FunctionalTests/Tools/GitProcess.cs b/GVFS/GVFS.FunctionalTests/Tools/GitProcess.cs
index bc2135465..abc94f06d 100644
--- a/GVFS/GVFS.FunctionalTests/Tools/GitProcess.cs
+++ b/GVFS/GVFS.FunctionalTests/Tools/GitProcess.cs
@@ -35,6 +35,9 @@ public static ProcessResult InvokeProcess(
}
processInfo.EnvironmentVariables["GIT_TERMINAL_PROMPT"] = "0";
+ // Suppress progress output (e.g. "Updating files: 100%") which is timing-dependent
+ // and causes flaky stderr comparisons between control and GVFS repos.
+ processInfo.EnvironmentVariables["GIT_PROGRESS_DELAY"] = "3600";
if (environmentVariables != null)
{
diff --git a/GVFS/GVFS.Hooks/Program.cs b/GVFS/GVFS.Hooks/Program.cs
index e9e3fb537..aee260928 100644
--- a/GVFS/GVFS.Hooks/Program.cs
+++ b/GVFS/GVFS.Hooks/Program.cs
@@ -3,6 +3,7 @@
using GVFS.Common.NamedPipes;
using GVFS.Common.Tracing;
using GVFS.Hooks.HooksPlatform;
+using GVFS.Platform.Windows;
using System;
using System.Collections.Generic;
using System.IO;
@@ -47,9 +48,21 @@ public static void Main(string[] args)
if (!GVFSHooksPlatform.TryGetGVFSEnlistmentRoot(Environment.CurrentDirectory, out enlistmentRoot, out errorMessage))
{
- // Nothing to hook when being run outside of a GVFS repo.
- // This is also the path when run with --git-dir outside of a GVFS directory, see Story #949665
- Environment.Exit(0);
+ // .gvfs walk-up failed — this may be a worktree placed
+ // outside the primary enlistment tree. Try resolving
+ // the enlistment root through the worktree chain.
+ GVFSEnlistment.WorktreeInfo wtInfo = GVFSEnlistment.TryGetWorktreeInfo(normalizedCurrentDirectory);
+ if (wtInfo != null)
+ {
+ enlistmentRoot = wtInfo.GetEnlistmentRoot();
+ }
+
+ if (enlistmentRoot == null ||
+ !Directory.Exists(Path.Combine(enlistmentRoot, WindowsPlatform.DotGVFSRoot)))
+ {
+ // Not in a GVFS repo or worktree. Nothing to hook.
+ Environment.Exit(0);
+ }
}
enlistmentPipename = GVFSHooksPlatform.GetNamedPipeName(enlistmentRoot);
diff --git a/GVFS/GVFS.NativeHooks.Common/common.windows.cpp b/GVFS/GVFS.NativeHooks.Common/common.windows.cpp
index 88e85f9d4..67137fbba 100644
--- a/GVFS/GVFS.NativeHooks.Common/common.windows.cpp
+++ b/GVFS/GVFS.NativeHooks.Common/common.windows.cpp
@@ -3,6 +3,7 @@
#include
#include
#include
+#include
#include "common.h"
PATH_STRING GetFinalPathName(const PATH_STRING& path)
@@ -53,6 +54,112 @@ PATH_STRING GetFinalPathName(const PATH_STRING& path)
return finalPath;
}
+// Reads the first line of a UTF-8 text file into a std::string.
+// Returns false if the file cannot be opened or read.
+static bool ReadFirstLine(const PATH_STRING& filePath, std::string& line)
+{
+ FILE* file = NULL;
+ errno_t err = _wfopen_s(&file, filePath.c_str(), L"r");
+ if (err != 0 || file == NULL)
+ return false;
+
+ char buffer[4096];
+ if (fgets(buffer, sizeof(buffer), file) == NULL)
+ {
+ fclose(file);
+ return false;
+ }
+ fclose(file);
+
+ line = buffer;
+
+ // Trim trailing whitespace / newlines
+ while (!line.empty() && (line.back() == '\n' || line.back() == '\r' || line.back() == ' '))
+ line.pop_back();
+
+ return true;
+}
+
+// Converts a UTF-8 string to a wide string.
+static PATH_STRING Utf8ToWide(const std::string& utf8)
+{
+ if (utf8.empty())
+ return PATH_STRING();
+
+ int wideLen = MultiByteToWideChar(CP_UTF8, 0, utf8.c_str(), -1, NULL, 0);
+ if (wideLen <= 0)
+ return PATH_STRING();
+
+ PATH_STRING wide(wideLen, L'\0');
+ MultiByteToWideChar(CP_UTF8, 0, utf8.c_str(), -1, &wide[0], wideLen);
+ wide.resize(wideLen - 1);
+ return wide;
+}
+
+// Checks if a directory exists at the given path.
+static bool DirectoryExists(const PATH_STRING& path)
+{
+ DWORD attrs = GetFileAttributesW(path.c_str());
+ return attrs != INVALID_FILE_ATTRIBUTES && (attrs & FILE_ATTRIBUTE_DIRECTORY);
+}
+
+// Resolves a potentially relative path against a base directory.
+static PATH_STRING ResolvePath(const PATH_STRING& basePath, const PATH_STRING& relativePath)
+{
+ PATH_STRING combined;
+ if (relativePath.length() >= 2 && relativePath[1] == L':')
+ {
+ combined = relativePath;
+ }
+ else
+ {
+ combined = basePath;
+ if (!combined.empty() && combined.back() != L'\\')
+ combined += L'\\';
+ combined += relativePath;
+ }
+
+ wchar_t resolved[MAX_PATH];
+ DWORD len = GetFullPathNameW(combined.c_str(), MAX_PATH, resolved, NULL);
+ if (len == 0 || len >= MAX_PATH)
+ return combined;
+
+ return PATH_STRING(resolved);
+}
+
+// Parses a .git file to extract the resolved gitdir path and
+// worktree name (last component of gitdir path).
+static bool TryParseGitFile(
+ const PATH_STRING& dotGitFilePath,
+ const PATH_STRING& containingDir,
+ PATH_STRING& resolvedGitdir,
+ std::string& worktreeName)
+{
+ std::string gitdirLine;
+ if (!ReadFirstLine(dotGitFilePath, gitdirLine))
+ return false;
+
+ const char* prefix = "gitdir: ";
+ if (gitdirLine.compare(0, 8, prefix) != 0)
+ return false;
+
+ std::string gitdirPath = gitdirLine.substr(8);
+ if (gitdirPath.empty())
+ return false;
+
+ std::replace(gitdirPath.begin(), gitdirPath.end(), '/', '\\');
+
+ size_t lastSep = gitdirPath.find_last_of('\\');
+ if (lastSep == std::string::npos || lastSep == gitdirPath.length() - 1)
+ return false;
+
+ worktreeName = gitdirPath.substr(lastSep + 1);
+
+ PATH_STRING wideGitdir = Utf8ToWide(gitdirPath);
+ resolvedGitdir = ResolvePath(containingDir, wideGitdir);
+ return true;
+}
+
// Checks if the given directory is a git worktree by looking for a
// ".git" file (not directory). If found, reads it to extract the
// worktree name and returns a pipe name suffix like "_WT_NAME".
@@ -71,53 +178,101 @@ PATH_STRING GetWorktreePipeSuffix(const wchar_t* directory)
return PATH_STRING();
}
- // .git is a file — this is a worktree. Read it to find the
- // worktree git directory (format: "gitdir: ")
- FILE* gitFile = NULL;
- errno_t fopenResult = _wfopen_s(&gitFile, dotGitPath.c_str(), L"r");
- if (fopenResult != 0 || gitFile == NULL)
+ PATH_STRING resolvedGitdir;
+ std::string worktreeName;
+ if (!TryParseGitFile(dotGitPath, PATH_STRING(directory), resolvedGitdir, worktreeName))
return PATH_STRING();
- char gitdirLine[4096];
- if (fgets(gitdirLine, sizeof(gitdirLine), gitFile) == NULL)
- {
- fclose(gitFile);
- return PATH_STRING();
- }
- fclose(gitFile);
-
- char* gitdirPath = gitdirLine;
- if (strncmp(gitdirPath, "gitdir: ", 8) == 0)
- gitdirPath += 8;
-
- // Trim trailing whitespace
- size_t lineLen = strlen(gitdirPath);
- while (lineLen > 0 && (gitdirPath[lineLen - 1] == '\n' ||
- gitdirPath[lineLen - 1] == '\r' ||
- gitdirPath[lineLen - 1] == ' '))
- gitdirPath[--lineLen] = '\0';
-
- // Extract worktree name — last path component
- // e.g., from ".git/worktrees/my-worktree" extract "my-worktree"
- char* lastSep = strrchr(gitdirPath, '/');
- if (!lastSep)
- lastSep = strrchr(gitdirPath, '\\');
-
- if (lastSep == NULL)
+ // Verify this is actually a worktree (has commondir file)
+ PATH_STRING commondirFile = resolvedGitdir + L"\\commondir";
+ std::string commondirContent;
+ if (!ReadFirstLine(commondirFile, commondirContent))
return PATH_STRING();
- std::string nameUtf8(lastSep + 1);
- int wideLen = MultiByteToWideChar(CP_UTF8, 0, nameUtf8.c_str(), -1, NULL, 0);
- if (wideLen <= 0)
- return PATH_STRING();
+ PATH_STRING suffix = L"_WT_" + Utf8ToWide(worktreeName);
+ return suffix;
+}
- std::wstring wtName(wideLen, L'\0');
- MultiByteToWideChar(CP_UTF8, 0, nameUtf8.c_str(), -1, &wtName[0], wideLen);
- wtName.resize(wideLen - 1); // remove null terminator from string
+// Walks up from startDirectory looking for a ".git" file (not directory)
+// indicating a git worktree. If found, resolves the primary GVFS
+// enlistment root through the worktree's gitdir chain:
+// 1. Read gvfs-enlistment-root marker (preferred)
+// 2. Fall back to commondir -> shared .git dir -> parent -> parent
+// Validates that the resolved root contains a .gvfs directory.
+static bool TryResolveFromWorktree(
+ const PATH_STRING& startDirectory,
+ PATH_STRING& enlistmentRoot,
+ PATH_STRING& pipeSuffix)
+{
+ PATH_STRING current = startDirectory;
+ while (true)
+ {
+ PATH_STRING dotGitPath = current + L"\\.git";
+ DWORD attrs = GetFileAttributesW(dotGitPath.c_str());
- PATH_STRING suffix = L"_WT_";
- suffix += wtName;
- return suffix;
+ if (attrs != INVALID_FILE_ATTRIBUTES && !(attrs & FILE_ATTRIBUTE_DIRECTORY))
+ {
+ PATH_STRING resolvedGitdir;
+ std::string worktreeName;
+ if (!TryParseGitFile(dotGitPath, current, resolvedGitdir, worktreeName))
+ return false;
+
+ PATH_STRING commondirFile = resolvedGitdir + L"\\commondir";
+ std::string commondirContent;
+ if (!ReadFirstLine(commondirFile, commondirContent))
+ return false;
+
+ // Try gvfs-enlistment-root marker first (written during
+ // git worktree add by the managed hooks)
+ PATH_STRING markerFile = resolvedGitdir + L"\\gvfs-enlistment-root";
+ std::string markerContent;
+ if (ReadFirstLine(markerFile, markerContent) && !markerContent.empty())
+ {
+ std::replace(markerContent.begin(), markerContent.end(), '/', '\\');
+ enlistmentRoot = ResolvePath(resolvedGitdir, Utf8ToWide(markerContent));
+ }
+ else
+ {
+ // Fall back: commondir -> shared .git dir -> src/ -> enlistment root
+ std::replace(commondirContent.begin(), commondirContent.end(), '/', '\\');
+ PATH_STRING sharedGitDir = ResolvePath(resolvedGitdir, Utf8ToWide(commondirContent));
+
+ // SharedGitDir = /src/.git
+ size_t sep = sharedGitDir.find_last_of(L'\\');
+ if (sep == std::wstring::npos)
+ return false;
+ PATH_STRING srcDir = sharedGitDir.substr(0, sep);
+
+ sep = srcDir.find_last_of(L'\\');
+ if (sep == std::wstring::npos)
+ return false;
+ enlistmentRoot = srcDir.substr(0, sep);
+ }
+
+ // Validate: the resolved root must contain .gvfs
+ if (!DirectoryExists(enlistmentRoot + L"\\.gvfs"))
+ return false;
+
+ pipeSuffix = L"_WT_" + Utf8ToWide(worktreeName);
+ return true;
+ }
+
+ if (attrs != INVALID_FILE_ATTRIBUTES && (attrs & FILE_ATTRIBUTE_DIRECTORY))
+ {
+ // Found a .git directory - primary repo, not a worktree
+ return false;
+ }
+
+ size_t sep = current.find_last_of(L'\\');
+ if (sep == std::wstring::npos || sep == 0)
+ return false;
+
+ PATH_STRING parent = current.substr(0, sep);
+ if (parent == current)
+ return false;
+
+ current = parent;
+ }
}
PATH_STRING GetGVFSPipeName(const char *appName)
@@ -126,18 +281,26 @@ PATH_STRING GetGVFSPipeName(const char *appName)
// Start in the current directory and walk up the directory tree
// until we find a folder that contains the ".gvfs" folder.
// For worktrees, a suffix is appended to target the worktree's mount.
+ //
+ // If .gvfs walk-up fails, fall back to worktree detection: walk up
+ // looking for a .git file, then resolve the primary enlistment root
+ // through the worktree's gitdir chain.
const size_t dotGVFSRelativePathLength = sizeof(L"\\.gvfs") / sizeof(wchar_t);
// TODO 640838: Support paths longer than MAX_PATH
- wchar_t enlistmentRoot[MAX_PATH];
- DWORD currentDirResult = GetCurrentDirectoryW(MAX_PATH - dotGVFSRelativePathLength, enlistmentRoot);
+ wchar_t currentDir[MAX_PATH];
+ DWORD currentDirResult = GetCurrentDirectoryW(MAX_PATH - dotGVFSRelativePathLength, currentDir);
if (currentDirResult == 0 || currentDirResult > MAX_PATH - dotGVFSRelativePathLength)
{
die(ReturnCode::GetCurrentDirectoryFailure, "GetCurrentDirectory failed (%d)\n", GetLastError());
}
- PATH_STRING finalRootPath(GetFinalPathName(enlistmentRoot));
+ PATH_STRING finalRootPath(GetFinalPathName(currentDir));
+
+ // Phase 1: Try .gvfs walk-up (the common case for primary enlistments
+ // and worktrees placed under the enlistment root)
+ wchar_t enlistmentRoot[MAX_PATH];
errno_t copyResult = wcscpy_s(enlistmentRoot, finalRootPath.c_str());
if (copyResult != 0)
{
@@ -151,7 +314,7 @@ PATH_STRING GetGVFSPipeName(const char *appName)
enlistmentRootLength++;
}
- // Walk up enlistmentRoot looking for a folder named .gvfs
+ bool foundGvfs = false;
wchar_t* lastslash = enlistmentRoot + enlistmentRootLength - 1;
WIN32_FIND_DATAW findFileData;
HANDLE dotGVFSHandle;
@@ -164,6 +327,7 @@ PATH_STRING GetGVFSPipeName(const char *appName)
FindClose(dotGVFSHandle);
if (findFileData.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)
{
+ foundGvfs = true;
break;
}
}
@@ -176,28 +340,50 @@ PATH_STRING GetGVFSPipeName(const char *appName)
if (enlistmentRoot == lastslash)
{
- die(ReturnCode::NotInGVFSEnlistment, "%s must be run from inside a GVFS enlistment\n", appName);
+ break;
}
*(lastslash + 1) = 0;
- };
+ }
+
+ if (foundGvfs)
+ {
+ *(lastslash) = 0;
- *(lastslash) = 0;
+ PATH_STRING namedPipe(CharUpperW(enlistmentRoot));
+ std::replace(namedPipe.begin(), namedPipe.end(), L':', L'_');
+ PATH_STRING pipeName = L"\\\\.\\pipe\\GVFS_" + namedPipe;
- PATH_STRING namedPipe(CharUpperW(enlistmentRoot));
- std::replace(namedPipe.begin(), namedPipe.end(), L':', L'_');
- PATH_STRING pipeName = L"\\\\.\\pipe\\GVFS_" + namedPipe;
+ PATH_STRING worktreeSuffix = GetWorktreePipeSuffix(finalRootPath.c_str());
+ if (!worktreeSuffix.empty())
+ {
+ std::transform(worktreeSuffix.begin(), worktreeSuffix.end(),
+ worktreeSuffix.begin(), ::towupper);
+ pipeName += worktreeSuffix;
+ }
- // Append worktree suffix if running in a worktree
- PATH_STRING worktreeSuffix = GetWorktreePipeSuffix(finalRootPath.c_str());
- if (!worktreeSuffix.empty())
+ return pipeName;
+ }
+
+ // Phase 2: .gvfs not found - try worktree fallback
+ PATH_STRING resolvedRoot;
+ PATH_STRING worktreeSuffix;
+ if (TryResolveFromWorktree(finalRootPath, resolvedRoot, worktreeSuffix))
{
+ std::transform(resolvedRoot.begin(), resolvedRoot.end(),
+ resolvedRoot.begin(), ::towupper);
+ std::replace(resolvedRoot.begin(), resolvedRoot.end(), L':', L'_');
+ PATH_STRING pipeName = L"\\\\.\\pipe\\GVFS_" + resolvedRoot;
+
std::transform(worktreeSuffix.begin(), worktreeSuffix.end(),
worktreeSuffix.begin(), ::towupper);
pipeName += worktreeSuffix;
+
+ return pipeName;
}
- return pipeName;
+ die(ReturnCode::NotInGVFSEnlistment, "%s must be run from inside a GVFS enlistment\n", appName);
+ return PATH_STRING();
}
PIPE_HANDLE CreatePipeToGVFS(const PATH_STRING& pipeName)
diff --git a/GVFS/GVFS.Platform.Windows/WindowsPlatform.Shared.cs b/GVFS/GVFS.Platform.Windows/WindowsPlatform.Shared.cs
index 80cc09f68..99def6841 100644
--- a/GVFS/GVFS.Platform.Windows/WindowsPlatform.Shared.cs
+++ b/GVFS/GVFS.Platform.Windows/WindowsPlatform.Shared.cs
@@ -70,6 +70,44 @@ public static bool IsProcessActiveImplementation(int processId, bool tryGetProce
}
}
+ ///
+ /// Returns true if a process with the given PID is currently active AND writes its
+ /// creation timestamp (raw FILETIME, 100-ns ticks since 1601) to .
+ /// The returned value is intended for identity comparison only -- two calls for the
+ /// same underlying process (no PID reuse) always yield equal values; if the OS recycles
+ /// a PID to a new process the value will differ. Returns false (and startTime = 0)
+ /// if the process is gone, has terminated (but its kernel object lingers due to an
+ /// outstanding handle elsewhere), or cannot be opened for QueryLimitedInformation.
+ ///
+ public static bool TryGetActiveProcessStartTimeImplementation(int processId, out long startTime)
+ {
+ startTime = 0;
+
+ using (SafeFileHandle process = NativeMethods.OpenProcess(NativeMethods.ProcessAccessFlags.QueryLimitedInformation, false, processId))
+ {
+ if (process.IsInvalid)
+ {
+ return false;
+ }
+
+ // GetProcessTimes succeeds for terminated processes whose kernel object still
+ // exists (e.g., an outstanding handle elsewhere). Confirm the process is still
+ // running before trusting the creation time as an identity marker.
+ if (!NativeMethods.GetExitCodeProcess(process, out uint exitCode) || exitCode != StillActive)
+ {
+ return false;
+ }
+
+ if (!NativeMethods.GetProcessTimes(process, out long creationTime, out _, out _, out _))
+ {
+ return false;
+ }
+
+ startTime = creationTime;
+ return true;
+ }
+ }
+
public static string GetNamedPipeNameImplementation(string enlistmentRoot)
{
return "GVFS_" + enlistmentRoot.ToUpper().Replace(':', '_');
diff --git a/GVFS/GVFS.Platform.Windows/WindowsPlatform.cs b/GVFS/GVFS.Platform.Windows/WindowsPlatform.cs
index d262a7953..b8593f417 100644
--- a/GVFS/GVFS.Platform.Windows/WindowsPlatform.cs
+++ b/GVFS/GVFS.Platform.Windows/WindowsPlatform.cs
@@ -205,6 +205,11 @@ public override bool IsProcessActive(int processId)
return WindowsPlatform.IsProcessActiveImplementation(processId, tryGetProcessById: true);
}
+ public override bool TryGetActiveProcessStartTime(int processId, out long startTime)
+ {
+ return WindowsPlatform.TryGetActiveProcessStartTimeImplementation(processId, out startTime);
+ }
+
public override void IsServiceInstalledAndRunning(string name, out bool installed, out bool running)
{
ServiceController service = ServiceController.GetServices().FirstOrDefault(s => s.ServiceName.Equals(name, StringComparison.Ordinal));
diff --git a/GVFS/GVFS.UnitTests/Common/GVFSLockTests.cs b/GVFS/GVFS.UnitTests/Common/GVFSLockTests.cs
index fb0a7c5a4..91173fd7b 100644
--- a/GVFS/GVFS.UnitTests/Common/GVFSLockTests.cs
+++ b/GVFS/GVFS.UnitTests/Common/GVFSLockTests.cs
@@ -182,6 +182,84 @@ public void TryAcquireLockForExternalRequestor_WhenExternalHolderTerminated()
mockTracer.VerifyAll();
}
+ ///
+ /// Regression test for OrphanedGVFSLockIsCleanedUp flake: simulates the original lock holder
+ /// exiting and the OS recycling its PID to a different, unrelated process before the orphan
+ /// check fires. Identity is preserved via process start time, so the orphan must be detected
+ /// even though the (now-different) process at the holder's PID still appears active.
+ ///
+ [TestCase]
+ public void TryAcquireLockForExternalRequestor_WhenHolderPidRecycled()
+ {
+ Mock mockTracer = new Mock(MockBehavior.Strict);
+ mockTracer.Setup(x => x.RelatedEvent(EventLevel.Informational, "TryAcquireLockExternal", It.IsAny()));
+ mockTracer.Setup(x => x.RelatedEvent(EventLevel.Informational, "ExternalLockHolderExited", It.IsAny(), Keywords.Telemetry));
+ mockTracer.Setup(x => x.SetGitCommandSessionId(string.Empty));
+ MockPlatform mockPlatform = (MockPlatform)GVFSPlatform.Instance;
+
+ // Acquire as DefaultLockData.PID with an explicit start time so the new identity-check
+ // path (not the legacy fallback) runs.
+ mockPlatform.ActiveProcesses.Add(DefaultLockData.PID);
+ mockPlatform.ProcessStartTimes[DefaultLockData.PID] = 1000;
+ GVFSLock gvfsLock = new GVFSLock(mockTracer.Object);
+ NamedPipeMessages.LockData existingExternalHolder;
+ gvfsLock.TryAcquireLockForExternalRequestor(DefaultLockData, out existingExternalHolder).ShouldBeTrue();
+ existingExternalHolder.ShouldBeNull();
+ this.ValidateLockHeld(gvfsLock, DefaultLockData);
+
+ // Simulate PID recycling: the PID is still reported active (a different process now owns it),
+ // but the start time differs from the one we captured at acquisition.
+ mockPlatform.ProcessStartTimes[DefaultLockData.PID] = 2000;
+
+ NamedPipeMessages.LockData newLockData = new NamedPipeMessages.LockData(4321, false, false, "git new", "456");
+ mockPlatform.ActiveProcesses.Add(newLockData.PID);
+ mockPlatform.ProcessStartTimes[newLockData.PID] = 3000;
+ gvfsLock.TryAcquireLockForExternalRequestor(newLockData, out existingExternalHolder).ShouldBeTrue();
+ existingExternalHolder.ShouldBeNull();
+ this.ValidateLockHeld(gvfsLock, newLockData);
+
+ mockPlatform.ActiveProcesses.Remove(DefaultLockData.PID);
+ mockPlatform.ActiveProcesses.Remove(newLockData.PID);
+ mockPlatform.ProcessStartTimes.Remove(DefaultLockData.PID);
+ mockPlatform.ProcessStartTimes.Remove(newLockData.PID);
+ mockTracer.VerifyAll();
+ }
+
+ ///
+ /// Inverse of the PID-recycle test: when start time still matches, the original holder
+ /// must still be considered the active holder and a new acquisition must be denied.
+ /// Guards against an over-eager orphan detector that would release any lock on every check.
+ ///
+ [TestCase]
+ public void TryAcquireLockForExternalRequestor_WhenHolderStartTimeMatches()
+ {
+ Mock mockTracer = new Mock(MockBehavior.Strict);
+ mockTracer.Setup(x => x.RelatedEvent(EventLevel.Informational, "TryAcquireLockExternal", It.IsAny()));
+ mockTracer.Setup(x => x.RelatedEvent(EventLevel.Verbose, "TryAcquireLockExternal", It.IsAny()));
+ MockPlatform mockPlatform = (MockPlatform)GVFSPlatform.Instance;
+
+ mockPlatform.ActiveProcesses.Add(DefaultLockData.PID);
+ mockPlatform.ProcessStartTimes[DefaultLockData.PID] = 1000;
+ GVFSLock gvfsLock = new GVFSLock(mockTracer.Object);
+ NamedPipeMessages.LockData existingExternalHolder;
+ gvfsLock.TryAcquireLockForExternalRequestor(DefaultLockData, out existingExternalHolder).ShouldBeTrue();
+ this.ValidateLockHeld(gvfsLock, DefaultLockData);
+
+ // Start time is unchanged -- holder is genuinely still alive; new acquire must be denied.
+ NamedPipeMessages.LockData newLockData = new NamedPipeMessages.LockData(4321, false, false, "git new", "456");
+ mockPlatform.ActiveProcesses.Add(newLockData.PID);
+ mockPlatform.ProcessStartTimes[newLockData.PID] = 3000;
+ gvfsLock.TryAcquireLockForExternalRequestor(newLockData, out existingExternalHolder).ShouldBeFalse();
+ this.ValidateExistingExternalHolder(DefaultLockData, existingExternalHolder);
+ this.ValidateLockHeld(gvfsLock, DefaultLockData);
+
+ mockPlatform.ActiveProcesses.Remove(DefaultLockData.PID);
+ mockPlatform.ActiveProcesses.Remove(newLockData.PID);
+ mockPlatform.ProcessStartTimes.Remove(DefaultLockData.PID);
+ mockPlatform.ProcessStartTimes.Remove(newLockData.PID);
+ mockTracer.VerifyAll();
+ }
+
private GVFSLock AcquireDefaultLock(MockPlatform mockPlatform, ITracer mockTracer)
{
GVFSLock gvfsLock = new GVFSLock(mockTracer);
diff --git a/GVFS/GVFS.UnitTests/Common/WorktreeInfoTests.cs b/GVFS/GVFS.UnitTests/Common/WorktreeInfoTests.cs
index 9ebe56963..558493dfa 100644
--- a/GVFS/GVFS.UnitTests/Common/WorktreeInfoTests.cs
+++ b/GVFS/GVFS.UnitTests/Common/WorktreeInfoTests.cs
@@ -183,5 +183,73 @@ public void ReturnsNullForPrimaryFromSubdirectory()
GVFSEnlistment.WorktreeInfo info = GVFSEnlistment.TryGetWorktreeInfo(subDir);
info.ShouldBeNull();
}
+
+ [TestCase]
+ public void GetEnlistmentRootReadsMarkerFile()
+ {
+ string enlistmentRoot = Path.Combine(this.testRoot, "enlistment");
+ string primaryGitDir = Path.Combine(enlistmentRoot, "src", ".git");
+ string worktreeGitDir = Path.Combine(primaryGitDir, "worktrees", "remote-wt");
+ Directory.CreateDirectory(worktreeGitDir);
+ File.WriteAllText(Path.Combine(worktreeGitDir, "commondir"), "../..");
+
+ // Write the marker file (same as hooks do during git worktree add)
+ File.WriteAllText(
+ Path.Combine(worktreeGitDir, GVFSEnlistment.WorktreeInfo.EnlistmentRootFileName),
+ enlistmentRoot);
+
+ // Worktree placed outside the enlistment tree
+ string worktreeDir = Path.Combine(this.testRoot, "remote-location", "remote-wt");
+ Directory.CreateDirectory(worktreeDir);
+ File.WriteAllText(Path.Combine(worktreeDir, ".git"), "gitdir: " + worktreeGitDir);
+
+ GVFSEnlistment.WorktreeInfo info = GVFSEnlistment.TryGetWorktreeInfo(worktreeDir);
+ info.ShouldNotBeNull();
+ info.GetEnlistmentRoot().ShouldEqual(enlistmentRoot);
+ }
+
+ [TestCase]
+ public void GetEnlistmentRootFallsBackToSharedGitDir()
+ {
+ string enlistmentRoot = Path.Combine(this.testRoot, "enlistment");
+ string primaryGitDir = Path.Combine(enlistmentRoot, "src", ".git");
+ string worktreeGitDir = Path.Combine(primaryGitDir, "worktrees", "fallback-wt");
+ Directory.CreateDirectory(worktreeGitDir);
+ File.WriteAllText(Path.Combine(worktreeGitDir, "commondir"), "../..");
+
+ // No marker file — fallback should derive from SharedGitDir
+ string worktreeDir = Path.Combine(this.testRoot, "elsewhere", "fallback-wt");
+ Directory.CreateDirectory(worktreeDir);
+ File.WriteAllText(Path.Combine(worktreeDir, ".git"), "gitdir: " + worktreeGitDir);
+
+ GVFSEnlistment.WorktreeInfo info = GVFSEnlistment.TryGetWorktreeInfo(worktreeDir);
+ info.ShouldNotBeNull();
+
+ // SharedGitDir = /src/.git → parent = src → parent = enlistmentRoot
+ info.GetEnlistmentRoot().ShouldEqual(enlistmentRoot);
+ }
+
+ [TestCase]
+ public void GetEnlistmentRootPrefersMarkerOverFallback()
+ {
+ string actualRoot = Path.Combine(this.testRoot, "actual-root");
+ string primaryGitDir = Path.Combine(this.testRoot, "different-structure", ".git");
+ string worktreeGitDir = Path.Combine(primaryGitDir, "worktrees", "marker-wt");
+ Directory.CreateDirectory(worktreeGitDir);
+ File.WriteAllText(Path.Combine(worktreeGitDir, "commondir"), "../..");
+
+ // Write marker pointing to a different root than what SharedGitDir would derive
+ File.WriteAllText(
+ Path.Combine(worktreeGitDir, GVFSEnlistment.WorktreeInfo.EnlistmentRootFileName),
+ actualRoot);
+
+ string worktreeDir = Path.Combine(this.testRoot, "marker-wt");
+ Directory.CreateDirectory(worktreeDir);
+ File.WriteAllText(Path.Combine(worktreeDir, ".git"), "gitdir: " + worktreeGitDir);
+
+ GVFSEnlistment.WorktreeInfo info = GVFSEnlistment.TryGetWorktreeInfo(worktreeDir);
+ info.ShouldNotBeNull();
+ info.GetEnlistmentRoot().ShouldEqual(actualRoot);
+ }
}
}
diff --git a/GVFS/GVFS.UnitTests/Mock/Common/MockPlatform.cs b/GVFS/GVFS.UnitTests/Mock/Common/MockPlatform.cs
index 8c153a424..41876f953 100644
--- a/GVFS/GVFS.UnitTests/Mock/Common/MockPlatform.cs
+++ b/GVFS/GVFS.UnitTests/Mock/Common/MockPlatform.cs
@@ -45,6 +45,14 @@ public override bool SupportsSystemInstallLog
public HashSet ActiveProcesses { get; } = new HashSet();
+ ///
+ /// Optional per-PID start-time overrides used by tests that exercise PID-reuse
+ /// detection. When a PID is in but not in this
+ /// dictionary, returns the PID itself
+ /// as a synthetic non-zero start time.
+ ///
+ public Dictionary ProcessStartTimes { get; } = new Dictionary();
+
public override void ConfigureVisualStudio(string gitBinPath, ITracer tracer)
{
throw new NotSupportedException();
@@ -138,6 +146,21 @@ public override bool IsProcessActive(int processId)
return this.ActiveProcesses.Contains(processId);
}
+ public override bool TryGetActiveProcessStartTime(int processId, out long startTime)
+ {
+ if (this.ActiveProcesses.Contains(processId))
+ {
+ // If the test populated an explicit start time use that, otherwise fall back
+ // to a deterministic non-zero value so test scenarios that don't care about
+ // PID identity (the existing majority of unit tests) keep working.
+ startTime = this.ProcessStartTimes.TryGetValue(processId, out long stored) ? stored : processId;
+ return true;
+ }
+
+ startTime = 0;
+ return false;
+ }
+
public override void IsServiceInstalledAndRunning(string name, out bool installed, out bool running)
{
throw new NotSupportedException();
diff --git a/GVFS/GitHooksLoader/GitHooksLoader.cpp b/GVFS/GitHooksLoader/GitHooksLoader.cpp
index dfb259b87..9f1619d0a 100644
--- a/GVFS/GitHooksLoader/GitHooksLoader.cpp
+++ b/GVFS/GitHooksLoader/GitHooksLoader.cpp
@@ -129,7 +129,7 @@ int ExecuteHook(const std::wstring &applicationName, wchar_t *hookName, int argc
/* Git disallows stdin from hooks */
si.dwFlags = STARTF_USESTDHANDLES;
- creationFlags |= CREATE_NO_WINDOW;
+ creationFlags |= DETACHED_PROCESS;
}
ZeroMemory(&pi, sizeof(pi));