Skip to content

Commit f42cf3d

Browse files
Tyrie VellaCopilot
andcommitted
Address PR feedback from mjcheetham and KeithIsSleeping
TryGetWorktreeInfo: - Walk up from subdirectories to find worktree root - Canonicalize directory to absolute path - Require commondir file for valid worktree (return null if missing) - Add out string error overload; callers fail or warn on IO errors - Add GitDirPrefix, CommonDirName, SkipCleanCheckName constants WorktreeCommandParser: - Handle combined short flags (e.g., -fd, -fb branch, -bfd) - Separate long/short option handling - Handle --git-pid/--exit_code as separate-value options - Document assumptions and note Mono.Options as future improvement Hooks: - Write empty marker file instead of "1" for skip-clean-check - Check unmount exit code; block git on failure unless --force - Reference PhysicalFileSystem.TryCopyToTempFileAndRename in comment Other: - Revert whitespace-only changes in InProcessMountVerb.cs - New unit tests for subdirectory detection, combined flags, baked-in values Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 65f0e2d commit f42cf3d

11 files changed

Lines changed: 266 additions & 49 deletions

File tree

GVFS/GVFS.Common/GVFSConstants.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,9 @@ public static class DotGit
146146
{
147147
public const string Root = ".git";
148148
public const string HeadName = "HEAD";
149+
public const string GitDirPrefix = "gitdir: ";
150+
public const string CommonDirName = "commondir";
151+
public const string SkipCleanCheckName = "skip-clean-check";
149152
public const string IndexName = "index";
150153
public const string PackedRefsName = "packed-refs";
151154
public const string LockExtension = ".lock";

GVFS/GVFS.Common/GVFSEnlistment.Shared.cs

Lines changed: 68 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -57,34 +57,81 @@ public static string GetWorktreePipeSuffix(string directory)
5757
}
5858

5959
/// <summary>
60-
/// Detects if the given directory is a git worktree. If so, returns
61-
/// a WorktreeInfo with the worktree name, git dir path, and shared
62-
/// git dir path. Returns null if not a worktree.
60+
/// Detects if the given directory (or any ancestor) is a git worktree.
61+
/// Walks up from <paramref name="directory"/> looking for a <c>.git</c>
62+
/// file (not directory) containing a <c>gitdir:</c> pointer. Returns
63+
/// null if not inside a worktree.
6364
/// </summary>
6465
public static WorktreeInfo TryGetWorktreeInfo(string directory)
6566
{
66-
string dotGitPath = Path.Combine(directory, ".git");
67+
return TryGetWorktreeInfo(directory, out _);
68+
}
6769

68-
if (!File.Exists(dotGitPath) || Directory.Exists(dotGitPath))
70+
/// <summary>
71+
/// Detects if the given directory (or any ancestor) is a git worktree.
72+
/// Walks up from <paramref name="directory"/> looking for a <c>.git</c>
73+
/// file (not directory) containing a <c>gitdir:</c> pointer. Returns
74+
/// null if not inside a worktree, with an error message if an I/O
75+
/// error prevented detection.
76+
/// </summary>
77+
public static WorktreeInfo TryGetWorktreeInfo(string directory, out string error)
78+
{
79+
error = null;
80+
81+
if (string.IsNullOrEmpty(directory))
6982
{
7083
return null;
7184
}
7285

86+
// Canonicalize to an absolute path so walk-up and Path.Combine
87+
// behave consistently regardless of the caller's CWD.
88+
string current = Path.GetFullPath(directory);
89+
while (current != null)
90+
{
91+
string dotGitPath = Path.Combine(current, ".git");
92+
93+
if (Directory.Exists(dotGitPath))
94+
{
95+
// Found a real .git directory — this is a primary worktree, not a linked worktree
96+
return null;
97+
}
98+
99+
if (File.Exists(dotGitPath))
100+
{
101+
return TryParseWorktreeGitFile(current, dotGitPath, out error);
102+
}
103+
104+
string parent = Path.GetDirectoryName(current);
105+
if (parent == current)
106+
{
107+
break;
108+
}
109+
110+
current = parent;
111+
}
112+
113+
return null;
114+
}
115+
116+
private static WorktreeInfo TryParseWorktreeGitFile(string worktreeRoot, string dotGitPath, out string error)
117+
{
118+
error = null;
119+
73120
try
74121
{
75122
string gitdirLine = File.ReadAllText(dotGitPath).Trim();
76-
if (!gitdirLine.StartsWith("gitdir: "))
123+
if (!gitdirLine.StartsWith(GVFSConstants.DotGit.GitDirPrefix))
77124
{
78125
return null;
79126
}
80127

81-
string gitdirPath = gitdirLine.Substring("gitdir: ".Length).Trim();
128+
string gitdirPath = gitdirLine.Substring(GVFSConstants.DotGit.GitDirPrefix.Length).Trim();
82129
gitdirPath = gitdirPath.Replace('/', Path.DirectorySeparatorChar);
83130

84131
// Resolve relative paths against the worktree directory
85132
if (!Path.IsPathRooted(gitdirPath))
86133
{
87-
gitdirPath = Path.GetFullPath(Path.Combine(directory, gitdirPath));
134+
gitdirPath = Path.GetFullPath(Path.Combine(worktreeRoot, gitdirPath));
88135
}
89136

90137
string worktreeName = Path.GetFileName(gitdirPath);
@@ -93,31 +140,34 @@ public static WorktreeInfo TryGetWorktreeInfo(string directory)
93140
return null;
94141
}
95142

96-
// Read commondir to find the shared .git/ directory
97-
// commondir file contains a relative path like "../../.."
98-
string commondirFile = Path.Combine(gitdirPath, "commondir");
99-
string sharedGitDir = null;
100-
if (File.Exists(commondirFile))
143+
// Read commondir to find the shared .git/ directory.
144+
// All valid worktrees must have a commondir file.
145+
string commondirFile = Path.Combine(gitdirPath, GVFSConstants.DotGit.CommonDirName);
146+
if (!File.Exists(commondirFile))
101147
{
102-
string commondirContent = File.ReadAllText(commondirFile).Trim();
103-
sharedGitDir = Path.GetFullPath(Path.Combine(gitdirPath, commondirContent));
148+
return null;
104149
}
105150

151+
string commondirContent = File.ReadAllText(commondirFile).Trim();
152+
string sharedGitDir = Path.GetFullPath(Path.Combine(gitdirPath, commondirContent));
153+
106154
return new WorktreeInfo
107155
{
108156
Name = worktreeName,
109-
WorktreePath = directory,
157+
WorktreePath = worktreeRoot,
110158
WorktreeGitDir = gitdirPath,
111159
SharedGitDir = sharedGitDir,
112160
PipeSuffix = "_WT_" + worktreeName.ToUpper(),
113161
};
114162
}
115-
catch (IOException)
163+
catch (IOException e)
116164
{
165+
error = e.Message;
117166
return null;
118167
}
119-
catch (UnauthorizedAccessException)
168+
catch (UnauthorizedAccessException e)
120169
{
170+
error = e.Message;
121171
return null;
122172
}
123173
}

GVFS/GVFS.Common/GVFSEnlistment.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,13 @@ public static GVFSEnlistment CreateFromDirectory(
138138
// Always check for worktree first. A worktree directory may
139139
// be under the enlistment tree, so TryGetGVFSEnlistmentRoot
140140
// can succeed by walking up — but we need a worktree enlistment.
141-
WorktreeInfo wtInfo = TryGetWorktreeInfo(directory);
141+
string worktreeError;
142+
WorktreeInfo wtInfo = TryGetWorktreeInfo(directory, out worktreeError);
143+
if (worktreeError != null)
144+
{
145+
throw new InvalidRepoException($"Failed to check worktree status for '{directory}': {worktreeError}");
146+
}
147+
142148
if (wtInfo?.SharedGitDir != null)
143149
{
144150
string primaryRoot = wtInfo.GetEnlistmentRoot();

GVFS/GVFS.Common/WorktreeCommandParser.cs

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,20 @@ namespace GVFS.Common
66
/// <summary>
77
/// Parses git worktree command arguments from hook args arrays.
88
/// Hook args format: [hooktype, "worktree", subcommand, options..., positional args..., --git-pid=N, --exit_code=N]
9+
///
10+
/// Assumptions:
11+
/// - Args are passed by git exactly as the user typed them (no normalization).
12+
/// - --git-pid and --exit_code are always appended by git in =value form.
13+
/// - Single-letter flags may be combined (e.g., -fd for --force --detach).
14+
/// - -b/-B always consume the next arg as a branch name, even when combined (e.g., -fb branch).
15+
///
16+
/// Future improvement: consider replacing with a POSIX-compatible arg parser
17+
/// library (e.g., Mono.Options, MIT license) to handle edge cases more robustly.
918
/// </summary>
1019
public static class WorktreeCommandParser
1120
{
21+
private static readonly HashSet<char> ShortOptionsWithValue = new HashSet<char> { 'b', 'B' };
22+
1223
/// <summary>
1324
/// Gets the worktree subcommand (add, remove, move, list, etc.) from hook args.
1425
/// </summary>
@@ -17,7 +28,7 @@ public static string GetSubcommand(string[] args)
1728
// args[0] = hook type, args[1] = "worktree", args[2+] = subcommand and its args
1829
for (int i = 2; i < args.Length; i++)
1930
{
20-
if (!args[i].StartsWith("--"))
31+
if (!args[i].StartsWith("-"))
2132
{
2233
return args[i].ToLowerInvariant();
2334
}
@@ -36,18 +47,24 @@ public static string GetSubcommand(string[] args)
3647
/// <param name="positionalIndex">0-based index of the positional arg after the subcommand</param>
3748
public static string GetPositionalArg(string[] args, int positionalIndex)
3849
{
39-
var optionsWithValue = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
50+
var longOptionsWithValue = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
4051
{
41-
"-b", "-B", "--reason"
52+
"--reason"
4253
};
4354

4455
int found = -1;
4556
bool pastSubcommand = false;
4657
bool pastSeparator = false;
4758
for (int i = 2; i < args.Length; i++)
4859
{
49-
if (args[i].StartsWith("--git-pid=") || args[i].StartsWith("--exit_code="))
60+
if (args[i].StartsWith("--git-pid") || args[i].StartsWith("--exit_code"))
5061
{
62+
// Always =value form, but skip either way
63+
if (!args[i].Contains("=") && i + 1 < args.Length)
64+
{
65+
i++;
66+
}
67+
5168
continue;
5269
}
5370

@@ -57,9 +74,40 @@ public static string GetPositionalArg(string[] args, int positionalIndex)
5774
continue;
5875
}
5976

60-
if (!pastSeparator && args[i].StartsWith("-"))
77+
if (!pastSeparator && args[i].StartsWith("--"))
78+
{
79+
// Long option — check if it takes a separate value
80+
if (longOptionsWithValue.Contains(args[i]) && i + 1 < args.Length)
81+
{
82+
i++;
83+
}
84+
85+
continue;
86+
}
87+
88+
if (!pastSeparator && args[i].StartsWith("-") && args[i].Length > 1)
6189
{
62-
if (optionsWithValue.Contains(args[i]) && i + 1 < args.Length)
90+
// Short option(s), possibly combined (e.g., -fd, -fb branch).
91+
// A value-taking letter consumes the rest of the arg as its value.
92+
// Only consume the next arg if the first value-taking letter is
93+
// the last character (no baked-in value).
94+
// e.g., -bfd → b="fd" (baked), -fdb val → f,d booleans, b="val"
95+
// -Bb → B="b" (baked), -fBb → f boolean, B="b" (baked)
96+
string flags = args[i].Substring(1);
97+
bool consumesNextArg = false;
98+
for (int j = 0; j < flags.Length; j++)
99+
{
100+
if (ShortOptionsWithValue.Contains(flags[j]))
101+
{
102+
// This letter takes a value. If it's the last letter,
103+
// the value is the next arg. Otherwise the value is the
104+
// remaining characters (baked in) and we're done.
105+
consumesNextArg = (j == flags.Length - 1);
106+
break;
107+
}
108+
}
109+
110+
if (consumesNextArg && i + 1 < args.Length)
63111
{
64112
i++;
65113
}

GVFS/GVFS.Hooks/Program.Worktree.cs

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,12 @@ private static void UnmountWorktreeByArg(string[] args)
7676
}
7777

7878
string fullPath = ResolvePath(worktreePath);
79-
UnmountWorktree(fullPath);
79+
if (!UnmountWorktree(fullPath))
80+
{
81+
Console.Error.WriteLine(
82+
$"error: failed to unmount worktree '{fullPath}'. Cannot proceed with move.");
83+
Environment.Exit(1);
84+
}
8085
}
8186

8287
/// <summary>
@@ -118,7 +123,7 @@ private static void CleanupSkipCleanCheckMarker(string[] args)
118123
GVFSEnlistment.WorktreeInfo wtInfo = GVFSEnlistment.TryGetWorktreeInfo(fullPath);
119124
if (wtInfo != null)
120125
{
121-
string markerPath = Path.Combine(wtInfo.WorktreeGitDir, "skip-clean-check");
126+
string markerPath = Path.Combine(wtInfo.WorktreeGitDir, GVFSConstants.DotGit.SkipCleanCheckName);
122127
if (File.Exists(markerPath))
123128
{
124129
File.Delete(markerPath);
@@ -222,32 +227,40 @@ private static void HandleWorktreeRemove(string[] args)
222227
// Write a marker in the worktree gitdir that tells git.exe
223228
// to skip the cleanliness check during worktree remove.
224229
// We already did our own check above while ProjFS was alive.
225-
string skipCleanCheck = Path.Combine(wtInfo.WorktreeGitDir, "skip-clean-check");
226-
File.WriteAllText(skipCleanCheck, "1");
230+
string skipCleanCheck = Path.Combine(wtInfo.WorktreeGitDir, GVFSConstants.DotGit.SkipCleanCheckName);
231+
File.WriteAllText(skipCleanCheck, string.Empty);
227232

228233
// Unmount ProjFS before git deletes the worktree directory.
229-
UnmountWorktree(fullPath, wtInfo);
234+
if (!UnmountWorktree(fullPath, wtInfo) && !hasForce)
235+
{
236+
Console.Error.WriteLine(
237+
$"error: failed to unmount worktree '{fullPath}'.\n" +
238+
$"Use 'git worktree remove --force' to attempt removal anyway.");
239+
Environment.Exit(1);
240+
}
230241
}
231242

232-
private static void UnmountWorktree(string fullPath)
243+
private static bool UnmountWorktree(string fullPath)
233244
{
234245
GVFSEnlistment.WorktreeInfo wtInfo = GVFSEnlistment.TryGetWorktreeInfo(fullPath);
235246
if (wtInfo == null)
236247
{
237-
return;
248+
return false;
238249
}
239250

240-
UnmountWorktree(fullPath, wtInfo);
251+
return UnmountWorktree(fullPath, wtInfo);
241252
}
242253

243-
private static void UnmountWorktree(string fullPath, GVFSEnlistment.WorktreeInfo wtInfo)
254+
private static bool UnmountWorktree(string fullPath, GVFSEnlistment.WorktreeInfo wtInfo)
244255
{
245-
ProcessHelper.Run("gvfs", $"unmount \"{fullPath}\"", redirectOutput: false);
256+
ProcessResult result = ProcessHelper.Run("gvfs", $"unmount \"{fullPath}\"", redirectOutput: false);
246257

247258
// After gvfs unmount exits, ProjFS handles may still be closing.
248259
// Wait briefly to allow the OS to release all handles before git
249260
// attempts to delete the worktree directory.
250261
System.Threading.Thread.Sleep(200);
262+
263+
return result.ExitCode == 0;
251264
}
252265

253266
private static void MountNewWorktree(string[] args)
@@ -264,7 +277,12 @@ private static void MountNewWorktree(string[] args)
264277
string dotGitFile = Path.Combine(fullPath, ".git");
265278
if (File.Exists(dotGitFile))
266279
{
267-
GVFSEnlistment.WorktreeInfo wtInfo = GVFSEnlistment.TryGetWorktreeInfo(fullPath);
280+
string worktreeError;
281+
GVFSEnlistment.WorktreeInfo wtInfo = GVFSEnlistment.TryGetWorktreeInfo(fullPath, out worktreeError);
282+
if (worktreeError != null)
283+
{
284+
Console.Error.WriteLine($"warning: failed to read worktree info for '{fullPath}': {worktreeError}");
285+
}
268286

269287
// Store the primary enlistment root so mount/unmount can find
270288
// it without deriving from path structure assumptions.
@@ -291,6 +309,9 @@ private static void MountNewWorktree(string[] args)
291309
// The primary index may be updated concurrently by the
292310
// running mount; a direct copy risks a torn read on
293311
// large indexes (200MB+ in some large repos).
312+
// Note: mirrors PhysicalFileSystem.TryCopyToTempFileAndRename
313+
// but that method requires GVFSPlatform which is not
314+
// available in the hooks process.
294315
string tempIndex = worktreeIndex + ".tmp";
295316
try
296317
{

GVFS/GVFS.Mount/InProcessMountVerb.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,11 @@ public InProcessMountVerb()
5757
HelpText = "Service initiated mount.")]
5858
public string StartedByService { get; set; }
5959

60-
[Option(
61-
'b',
62-
GVFSConstants.VerbParameters.Mount.StartedByVerb,
63-
Default = false,
64-
Required = false,
60+
[Option(
61+
'b',
62+
GVFSConstants.VerbParameters.Mount.StartedByVerb,
63+
Default = false,
64+
Required = false,
6565
HelpText = "Verb initiated mount.")]
6666
public bool StartedByVerb { get; set; }
6767

GVFS/GVFS.Service/GVFSMountProcess.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,14 @@ public bool MountRepository(string repoRoot, int sessionId)
3636

3737
string errorMessage;
3838
string pipeName = GVFSPlatform.Instance.GetNamedPipeName(repoRoot);
39-
GVFSEnlistment.WorktreeInfo wtInfo = GVFSEnlistment.TryGetWorktreeInfo(repoRoot);
39+
string worktreeError;
40+
GVFSEnlistment.WorktreeInfo wtInfo = GVFSEnlistment.TryGetWorktreeInfo(repoRoot, out worktreeError);
41+
if (worktreeError != null)
42+
{
43+
this.tracer.RelatedError($"Failed to check worktree status for '{repoRoot}': {worktreeError}");
44+
return false;
45+
}
46+
4047
if (wtInfo?.SharedGitDir != null)
4148
{
4249
string enlistmentRoot = wtInfo.GetEnlistmentRoot();

0 commit comments

Comments
 (0)