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.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 35c7db8d4..f837f4585 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) @@ -52,6 +53,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". @@ -70,53 +177,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) @@ -125,18 +280,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) { @@ -150,7 +313,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; @@ -163,6 +326,7 @@ PATH_STRING GetGVFSPipeName(const char *appName) FindClose(dotGVFSHandle); if (findFileData.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) { + foundGvfs = true; break; } } @@ -175,28 +339,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.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); + } } }