Skip to content

Commit 9d5c825

Browse files
Tyrie VellaCopilot
andcommitted
hooks: auto-mount/unmount worktrees via git hooks
In the managed pre/post-command hooks, intercept git worktree subcommands to transparently manage GVFS mounts: add: Post-command runs 'git checkout -f' to create the index, then 'gvfs mount' to start ProjFS projection. remove: Pre-command checks for uncommitted changes while ProjFS is alive, writes skip-clean-check marker, unmounts. Post-command remounts if removal failed (dir + .git exist). move: Pre-command unmounts old path, post-command mounts new. prune: Post-command cleans stale worktree metadata. Add WorktreeCommandParser reference to GVFS.Hooks.csproj. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 289ef9a commit 9d5c825

4 files changed

Lines changed: 420 additions & 2 deletions

File tree

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
using System;
2+
using System.Collections.Generic;
3+
4+
namespace GVFS.Common
5+
{
6+
/// <summary>
7+
/// Parses git worktree command arguments from hook args arrays.
8+
/// Hook args format: [hooktype, "worktree", subcommand, options..., positional args..., --git-pid=N, --exit_code=N]
9+
/// </summary>
10+
public static class WorktreeCommandParser
11+
{
12+
/// <summary>
13+
/// Gets the worktree subcommand (add, remove, move, list, etc.) from hook args.
14+
/// </summary>
15+
public static string GetSubcommand(string[] args)
16+
{
17+
// args[0] = hook type, args[1] = "worktree", args[2+] = subcommand and its args
18+
for (int i = 2; i < args.Length; i++)
19+
{
20+
if (!args[i].StartsWith("--"))
21+
{
22+
return args[i].ToLowerInvariant();
23+
}
24+
}
25+
26+
return null;
27+
}
28+
29+
/// <summary>
30+
/// Gets a positional argument from git worktree subcommand args.
31+
/// For 'add': git worktree add [options] &lt;path&gt; [&lt;commit-ish&gt;]
32+
/// For 'remove': git worktree remove [options] &lt;worktree&gt;
33+
/// For 'move': git worktree move [options] &lt;worktree&gt; &lt;new-path&gt;
34+
/// </summary>
35+
/// <param name="args">Full hook args array (hooktype, command, subcommand, ...)</param>
36+
/// <param name="positionalIndex">0-based index of the positional arg after the subcommand</param>
37+
public static string GetPositionalArg(string[] args, int positionalIndex)
38+
{
39+
var optionsWithValue = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
40+
{
41+
"-b", "-B", "--reason"
42+
};
43+
44+
int found = -1;
45+
bool pastSubcommand = false;
46+
bool pastSeparator = false;
47+
for (int i = 2; i < args.Length; i++)
48+
{
49+
if (args[i].StartsWith("--git-pid=") || args[i].StartsWith("--exit_code="))
50+
{
51+
continue;
52+
}
53+
54+
if (args[i] == "--")
55+
{
56+
pastSeparator = true;
57+
continue;
58+
}
59+
60+
if (!pastSeparator && args[i].StartsWith("-"))
61+
{
62+
if (optionsWithValue.Contains(args[i]) && i + 1 < args.Length)
63+
{
64+
i++;
65+
}
66+
67+
continue;
68+
}
69+
70+
if (!pastSubcommand)
71+
{
72+
pastSubcommand = true;
73+
continue;
74+
}
75+
76+
found++;
77+
if (found == positionalIndex)
78+
{
79+
return args[i];
80+
}
81+
}
82+
83+
return null;
84+
}
85+
86+
/// <summary>
87+
/// Gets the first positional argument (worktree path) from git worktree args.
88+
/// </summary>
89+
public static string GetPathArg(string[] args)
90+
{
91+
return GetPositionalArg(args, 0);
92+
}
93+
}
94+
}

GVFS/GVFS.Hooks/GVFS.Hooks.csproj

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@
7070
<Compile Include="..\GVFS.Common\ProcessResult.cs">
7171
<Link>Common\ProcessResult.cs</Link>
7272
</Compile>
73+
<Compile Include="..\GVFS.Common\WorktreeCommandParser.cs">
74+
<Link>Common\WorktreeCommandParser.cs</Link>
75+
</Compile>
7376
<Compile Include="..\GVFS.Common\SHA1Util.cs">
7477
<Link>Common\SHA1Util.cs</Link>
7578
</Compile>
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
using GVFS.Common;
2+
using GVFS.Common.NamedPipes;
3+
using GVFS.Hooks.HooksPlatform;
4+
using System;
5+
using System.IO;
6+
using System.Linq;
7+
8+
namespace GVFS.Hooks
9+
{
10+
public partial class Program
11+
{
12+
private static string GetWorktreeSubcommand(string[] args)
13+
{
14+
return WorktreeCommandParser.GetSubcommand(args);
15+
}
16+
17+
/// <summary>
18+
/// Gets a positional argument from git worktree subcommand args.
19+
/// For 'add': git worktree add [options] &lt;path&gt; [&lt;commit-ish&gt;]
20+
/// For 'remove': git worktree remove [options] &lt;worktree&gt;
21+
/// For 'move': git worktree move [options] &lt;worktree&gt; &lt;new-path&gt;
22+
/// </summary>
23+
private static string GetWorktreePositionalArg(string[] args, int positionalIndex)
24+
{
25+
return WorktreeCommandParser.GetPositionalArg(args, positionalIndex);
26+
}
27+
28+
private static string GetWorktreePathArg(string[] args)
29+
{
30+
return WorktreeCommandParser.GetPathArg(args);
31+
}
32+
33+
private static void RunWorktreePreCommand(string[] args)
34+
{
35+
string subcommand = GetWorktreeSubcommand(args);
36+
switch (subcommand)
37+
{
38+
case "remove":
39+
HandleWorktreeRemove(args);
40+
break;
41+
case "move":
42+
// Unmount at old location before git moves the directory
43+
UnmountWorktreeByArg(args);
44+
break;
45+
}
46+
}
47+
48+
private static void RunWorktreePostCommand(string[] args)
49+
{
50+
string subcommand = GetWorktreeSubcommand(args);
51+
switch (subcommand)
52+
{
53+
case "add":
54+
MountNewWorktree(args);
55+
break;
56+
case "remove":
57+
RemountWorktreeIfRemoveFailed(args);
58+
CleanupSkipCleanCheckMarker(args);
59+
break;
60+
case "move":
61+
// Mount at the new location after git moved the directory
62+
MountMovedWorktree(args);
63+
break;
64+
}
65+
}
66+
67+
private static void UnmountWorktreeByArg(string[] args)
68+
{
69+
string worktreePath = GetWorktreePathArg(args);
70+
if (string.IsNullOrEmpty(worktreePath))
71+
{
72+
return;
73+
}
74+
75+
string fullPath = ResolvePath(worktreePath);
76+
UnmountWorktree(fullPath);
77+
}
78+
79+
/// <summary>
80+
/// If the worktree directory and its .git file both still exist after
81+
/// git worktree remove, the removal failed completely. Remount ProjFS
82+
/// so the worktree remains usable. If the remove partially succeeded
83+
/// (e.g., .git file or gitdir removed), don't attempt recovery.
84+
/// </summary>
85+
private static void RemountWorktreeIfRemoveFailed(string[] args)
86+
{
87+
string worktreePath = GetWorktreePathArg(args);
88+
if (string.IsNullOrEmpty(worktreePath))
89+
{
90+
return;
91+
}
92+
93+
string fullPath = ResolvePath(worktreePath);
94+
string dotGitFile = Path.Combine(fullPath, ".git");
95+
if (Directory.Exists(fullPath) && File.Exists(dotGitFile))
96+
{
97+
ProcessHelper.Run("gvfs", $"mount \"{fullPath}\"", redirectOutput: false);
98+
}
99+
}
100+
101+
/// <summary>
102+
/// Remove the skip-clean-check marker if it still exists after
103+
/// worktree remove completes (e.g., if the remove failed and the
104+
/// worktree gitdir was not deleted).
105+
/// </summary>
106+
private static void CleanupSkipCleanCheckMarker(string[] args)
107+
{
108+
string worktreePath = GetWorktreePathArg(args);
109+
if (string.IsNullOrEmpty(worktreePath))
110+
{
111+
return;
112+
}
113+
114+
string fullPath = ResolvePath(worktreePath);
115+
GVFSEnlistment.WorktreeInfo wtInfo = GVFSEnlistment.TryGetWorktreeInfo(fullPath);
116+
if (wtInfo != null)
117+
{
118+
string markerPath = Path.Combine(wtInfo.WorktreeGitDir, "skip-clean-check");
119+
if (File.Exists(markerPath))
120+
{
121+
File.Delete(markerPath);
122+
}
123+
}
124+
}
125+
126+
private static void HandleWorktreeRemove(string[] args)
127+
{
128+
string worktreePath = GetWorktreePathArg(args);
129+
if (string.IsNullOrEmpty(worktreePath))
130+
{
131+
return;
132+
}
133+
134+
string fullPath = ResolvePath(worktreePath);
135+
GVFSEnlistment.WorktreeInfo wtInfo = GVFSEnlistment.TryGetWorktreeInfo(fullPath);
136+
if (wtInfo == null)
137+
{
138+
return;
139+
}
140+
141+
bool hasForce = args.Any(a =>
142+
a.Equals("--force", StringComparison.OrdinalIgnoreCase) ||
143+
a.Equals("-f", StringComparison.OrdinalIgnoreCase));
144+
145+
if (!hasForce)
146+
{
147+
// Check for uncommitted changes while ProjFS is still mounted.
148+
ProcessResult statusResult = ProcessHelper.Run(
149+
"git",
150+
$"-C \"{fullPath}\" status --porcelain",
151+
redirectOutput: true);
152+
153+
if (!string.IsNullOrWhiteSpace(statusResult.Output))
154+
{
155+
Console.Error.WriteLine(
156+
$"error: worktree '{fullPath}' has uncommitted changes.\n" +
157+
$"Use 'git worktree remove --force' to remove it anyway.");
158+
Environment.Exit(1);
159+
}
160+
}
161+
162+
// Write a marker in the worktree gitdir that tells git.exe
163+
// to skip the cleanliness check during worktree remove.
164+
// We already did our own check above while ProjFS was alive.
165+
string skipCleanCheck = Path.Combine(wtInfo.WorktreeGitDir, "skip-clean-check");
166+
File.WriteAllText(skipCleanCheck, "1");
167+
168+
// Unmount ProjFS before git deletes the worktree directory.
169+
UnmountWorktree(fullPath);
170+
}
171+
172+
private static void UnmountWorktree(string fullPath)
173+
{
174+
GVFSEnlistment.WorktreeInfo wtInfo = GVFSEnlistment.TryGetWorktreeInfo(fullPath);
175+
if (wtInfo == null)
176+
{
177+
return;
178+
}
179+
180+
ProcessHelper.Run("gvfs", $"unmount \"{fullPath}\"", redirectOutput: false);
181+
182+
// Wait for the GVFS.Mount process to fully exit by polling
183+
// the named pipe. Once the pipe is gone, the mount process
184+
// has released all file handles.
185+
string pipeName = GVFSHooksPlatform.GetNamedPipeName(enlistmentRoot) + wtInfo.PipeSuffix;
186+
for (int i = 0; i < 10; i++)
187+
{
188+
using (NamedPipeClient pipeClient = new NamedPipeClient(pipeName))
189+
{
190+
if (!pipeClient.Connect(100))
191+
{
192+
return;
193+
}
194+
}
195+
196+
System.Threading.Thread.Sleep(100);
197+
}
198+
}
199+
200+
private static void MountNewWorktree(string[] args)
201+
{
202+
string worktreePath = GetWorktreePathArg(args);
203+
if (string.IsNullOrEmpty(worktreePath))
204+
{
205+
return;
206+
}
207+
208+
string fullPath = ResolvePath(worktreePath);
209+
210+
// Verify worktree was created (check for .git file)
211+
string dotGitFile = Path.Combine(fullPath, ".git");
212+
if (File.Exists(dotGitFile))
213+
{
214+
GVFSEnlistment.WorktreeInfo wtInfo = GVFSEnlistment.TryGetWorktreeInfo(fullPath);
215+
216+
// Copy the primary's index to the worktree before checkout.
217+
// The primary index has all entries with correct skip-worktree
218+
// bits. If the worktree targets the same commit, checkout is
219+
// a no-op. If a different commit, git does an incremental
220+
// update — much faster than building 2.5M entries from scratch.
221+
if (wtInfo?.SharedGitDir != null)
222+
{
223+
string primaryIndex = Path.Combine(wtInfo.SharedGitDir, "index");
224+
string worktreeIndex = Path.Combine(wtInfo.WorktreeGitDir, "index");
225+
if (File.Exists(primaryIndex) && !File.Exists(worktreeIndex))
226+
{
227+
File.Copy(primaryIndex, worktreeIndex);
228+
}
229+
}
230+
231+
// Run checkout to reconcile the index with the worktree's HEAD.
232+
// With a pre-populated index this is fast (incremental diff).
233+
// Override core.virtualfilesystem with an empty script that
234+
// returns .gitattributes so it gets materialized while all
235+
// other entries keep skip-worktree set.
236+
//
237+
// Disable hooks via core.hookspath — the worktree's GVFS mount
238+
// doesn't exist yet, so post-index-change would fail trying
239+
// to connect to a pipe that hasn't been created.
240+
string emptyVfsHook = Path.Combine(fullPath, ".vfs-empty-hook");
241+
File.WriteAllText(emptyVfsHook, "#!/bin/sh\nprintf \".gitattributes\\n\"\n");
242+
string emptyVfsHookGitPath = emptyVfsHook.Replace('\\', '/');
243+
244+
ProcessHelper.Run(
245+
"git",
246+
$"-C \"{fullPath}\" -c core.virtualfilesystem=\"{emptyVfsHookGitPath}\" -c core.hookspath= checkout -f HEAD",
247+
redirectOutput: false);
248+
249+
File.Delete(emptyVfsHook);
250+
251+
// Hydrate .gitattributes — copy from the primary enlistment.
252+
if (wtInfo?.SharedGitDir != null)
253+
{
254+
string primarySrc = Path.GetDirectoryName(wtInfo.SharedGitDir);
255+
string primaryGitattributes = Path.Combine(primarySrc, ".gitattributes");
256+
string worktreeGitattributes = Path.Combine(fullPath, ".gitattributes");
257+
if (File.Exists(primaryGitattributes) && !File.Exists(worktreeGitattributes))
258+
{
259+
File.Copy(primaryGitattributes, worktreeGitattributes);
260+
}
261+
}
262+
263+
// Now mount GVFS — the index exists for GitIndexProjection
264+
ProcessHelper.Run("gvfs", $"mount \"{fullPath}\"", redirectOutput: false);
265+
}
266+
}
267+
268+
private static void MountMovedWorktree(string[] args)
269+
{
270+
// git worktree move <worktree> <new-path>
271+
// After move, the worktree is at <new-path>
272+
string newPath = GetWorktreePositionalArg(args, 1);
273+
if (string.IsNullOrEmpty(newPath))
274+
{
275+
return;
276+
}
277+
278+
string fullPath = ResolvePath(newPath);
279+
280+
string dotGitFile = Path.Combine(fullPath, ".git");
281+
if (File.Exists(dotGitFile))
282+
{
283+
ProcessHelper.Run("gvfs", $"mount \"{fullPath}\"", redirectOutput: false);
284+
}
285+
}
286+
}
287+
}

0 commit comments

Comments
 (0)