|
| 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] <path> [<commit-ish>] |
| 20 | + /// For 'remove': git worktree remove [options] <worktree> |
| 21 | + /// For 'move': git worktree move [options] <worktree> <new-path> |
| 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