From da656688d553541de5aa58b92178a21fb5ae3f78 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Fri, 5 Jun 2026 10:38:50 -0700 Subject: [PATCH] Mount: self-heal stale hook configurations Two related fixes that together let `gvfs mount` succeed against an enlistment whose pre-command hook is stale - typically because the GVFS install location baked into .git/hooks/pre-command.hooks at clone time has since moved (re-install, version-junction swap, system-to-user migration, etc.). Before this change, a stale .hooks text file causes every git invocation that fires the pre-command hook to fail with: fatal: pre-command hook aborted command which makes the mount path unrecoverable. Changes: HooksInstaller.TryUpdateHooks Also refresh the .hooks text files. Previously TryUpdateHooks only refreshed the .exe copies of GitHooksLoader; the .hooks text file (containing the absolute path of GVFS.Hooks.exe that the loader execs) was only written at clone time by InstallHooks. When the GVFS install moves, the .exe copies stay valid but the .hooks path goes stale - and gvfs.mount.exe's existing TryUpdateHooks call didn't repair it. The new TryInstallGitCommandHooks calls are idempotent: when the GVFS install path hasn't changed, the file is rewritten with the same content. GitProcess Pass usePreCommandHook:false on all git operations that run during the mount bootstrap path. These calls happen before gvfs.mount.exe reaches TryUpdateHooks, so without this flag they trip over the very stale-hook config we're trying to repair. Affected: SetInLocalConfig, AddInLocalConfig, DeleteFromLocalConfig, TryGetAllConfig, TryGetConfigUrlMatch, TryGetCredential, TryGetCertificatePassword, TryDeleteCredential, TryStoreCredential. GetFromConfig, GetFromLocalConfig and IsValidRepo also gain the flag (some via the existing GetOriginUrl pattern, some new). None of these operations mutate the working tree, so pre-command hook is semantically inappropriate anyway - skipping it is correct independent of the stale-hook scenario. The mechanism: usePreCommandHook:false sets the COMMAND_HOOK_LOCK environment variable, which Microsoft Git itself reads to suppress pre-command hook invocation. So the failure is bypassed at the git layer, not just inside GVFS.Hooks.exe. Testing: - 818/818 unit tests pass - Manually verified end-to-end with a real enlistment whose pre-command.hooks was corrupted to point at a non-existent path (C:\NonExistent\Path\GVFS.Hooks.exe). Before this change, `gvfs mount` failed with "pre-command hook aborted command". After this change, mount succeeds and the .hooks file is rewritten to point at the currently-running GVFS install. Assisted-by: Claude Opus 4.7 Signed-off-by: Tyrie Vella --- GVFS/GVFS.Common/FileSystem/HooksInstaller.cs | 22 +++++++ GVFS/GVFS.Common/Git/GitProcess.cs | 66 +++++++++++++------ 2 files changed, 69 insertions(+), 19 deletions(-) diff --git a/GVFS/GVFS.Common/FileSystem/HooksInstaller.cs b/GVFS/GVFS.Common/FileSystem/HooksInstaller.cs index bdf6a03cb..407918c67 100644 --- a/GVFS/GVFS.Common/FileSystem/HooksInstaller.cs +++ b/GVFS/GVFS.Common/FileSystem/HooksInstaller.cs @@ -124,6 +124,28 @@ public static bool TryUpdateHooks(GVFSContext context, out string errorMessage) return false; } + // Refresh the corresponding .hooks text files. These hold the + // absolute path of GVFS.Hooks.exe that the loader execs at hook + // time, and were originally written at clone time pointing at + // wherever GVFS was installed back then. If GVFS has moved + // (system-to-user migration, version-junction swap, hand-edited + // install), those paths go stale and the loader exits non-zero + // on every git invocation that fires a hook - making the + // enlistment unrecoverable through normal mount. Refreshing on + // every mount makes us self-healing against install-location + // drift, and is a no-op when paths are already current. + string precommandBasePath = Path.Combine(context.Enlistment.WorkingDirectoryBackingRoot, GVFSConstants.DotGit.Hooks.PreCommandPath); + if (!GVFSPlatform.Instance.TryInstallGitCommandHooks(context, ExecutingDirectory, GVFSConstants.DotGit.Hooks.PreCommandHookName, precommandBasePath, out errorMessage)) + { + return false; + } + + string postcommandBasePath = Path.Combine(context.Enlistment.WorkingDirectoryBackingRoot, GVFSConstants.DotGit.Hooks.PostCommandPath); + if (!GVFSPlatform.Instance.TryInstallGitCommandHooks(context, ExecutingDirectory, GVFSConstants.DotGit.Hooks.PostCommandHookName, postcommandBasePath, out errorMessage)) + { + return false; + } + return true; } diff --git a/GVFS/GVFS.Common/Git/GitProcess.cs b/GVFS/GVFS.Common/Git/GitProcess.cs index b818fd915..623a52f1f 100644 --- a/GVFS/GVFS.Common/Git/GitProcess.cs +++ b/GVFS/GVFS.Common/Git/GitProcess.cs @@ -191,7 +191,8 @@ public virtual bool TryDeleteCredential(ITracer tracer, string repoUrl, string u Result result = this.InvokeGitAgainstDotGitFolder( GenerateCredentialVerbCommand("reject"), stdin => stdin.Write(stdinConfig), - null); + null, + usePreCommandHook: false); if (result.ExitCodeIsFailure) { @@ -218,7 +219,8 @@ public virtual bool TryStoreCredential(ITracer tracer, string repoUrl, string us Result result = this.InvokeGitAgainstDotGitFolder( GenerateCredentialVerbCommand("approve"), stdin => stdin.Write(stdinConfig), - null); + null, + usePreCommandHook: false); if (result.ExitCodeIsFailure) { @@ -249,10 +251,13 @@ public virtual bool TryGetCertificatePassword( using (ITracer activity = tracer.StartActivity("TryGetCertificatePassword", EventLevel.Informational)) { + // See GetFromConfig for why pre-command hook is disabled + // for bootstrap-time git operations. Result gitCredentialOutput = this.InvokeGitAgainstDotGitFolder( "credential fill", stdin => stdin.Write("protocol=cert\npath=" + certificatePath + "\nusername=\n\n"), - parseStdOutLine: null); + parseStdOutLine: null, + usePreCommandHook: false); if (gitCredentialOutput.ExitCodeIsFailure) { @@ -300,10 +305,13 @@ public virtual bool TryGetCredential( using (ITracer activity = tracer.StartActivity(nameof(this.TryGetCredential), EventLevel.Informational)) { + // See GetFromConfig for why pre-command hook is disabled + // for bootstrap-time git operations. Result gitCredentialOutput = this.InvokeGitAgainstDotGitFolder( GenerateCredentialVerbCommand("fill"), stdin => stdin.Write($"url={repoUrl}\n\n"), - parseStdOutLine: null); + parseStdOutLine: null, + usePreCommandHook: false); if (gitCredentialOutput.ExitCodeIsFailure) { @@ -336,7 +344,10 @@ public virtual bool TryGetCredential( public bool IsValidRepo() { - Result result = this.InvokeGitAgainstDotGitFolder("rev-parse --show-toplevel"); + // Mount-time bootstrap check - skip pre-command hook so a broken + // hook config in the enlistment can be detected and repaired + // rather than blocking the mount that would fix it. + Result result = this.InvokeGitAgainstDotGitFolder("rev-parse --show-toplevel", usePreCommandHook: false); return result.ExitCodeIsSuccess; } @@ -352,24 +363,34 @@ public Result GetCurrentBranchName() public void DeleteFromLocalConfig(string settingName) { - this.InvokeGitAgainstDotGitFolder("config --local --unset-all " + settingName); + // git config operations never need the pre-command hook (no + // working-tree mutation). Skipping it also keeps mount bootstrap + // robust against a stale hook config that TryUpdateHooks will + // repair shortly. See GetFromConfig for the longer rationale. + this.InvokeGitAgainstDotGitFolder("config --local --unset-all " + settingName, usePreCommandHook: false); } public Result SetInLocalConfig(string settingName, string value, bool replaceAll = false) { - return this.InvokeGitAgainstDotGitFolder(string.Format( - "config --local {0} \"{1}\" \"{2}\"", - replaceAll ? "--replace-all " : string.Empty, - settingName, - value)); + // See DeleteFromLocalConfig for why pre-command hook is disabled. + return this.InvokeGitAgainstDotGitFolder( + string.Format( + "config --local {0} \"{1}\" \"{2}\"", + replaceAll ? "--replace-all " : string.Empty, + settingName, + value), + usePreCommandHook: false); } public Result AddInLocalConfig(string settingName, string value) { - return this.InvokeGitAgainstDotGitFolder(string.Format( - "config --local --add {0} {1}", - settingName, - value)); + // See DeleteFromLocalConfig for why pre-command hook is disabled. + return this.InvokeGitAgainstDotGitFolder( + string.Format( + "config --local --add {0} {1}", + settingName, + value), + usePreCommandHook: false); } public Result SetInFileConfig(string configFile, string settingName, string value, bool replaceAll = false) @@ -384,7 +405,8 @@ public Result SetInFileConfig(string configFile, string settingName, string valu public bool TryGetConfigUrlMatch(string section, string repositoryUrl, out Dictionary configSettings) { - Result result = this.InvokeGitAgainstDotGitFolder($"config --get-urlmatch {section} {repositoryUrl}"); + // See GetFromConfig for why pre-command hook is disabled. + Result result = this.InvokeGitAgainstDotGitFolder($"config --get-urlmatch {section} {repositoryUrl}", usePreCommandHook: false); if (result.ExitCodeIsFailure) { configSettings = null; @@ -399,7 +421,8 @@ public bool TryGetAllConfig(bool localOnly, out Dictionary