Skip to content

Commit 3d08ce4

Browse files
authored
Merge pull request #1964 from erickulcyk/erickul/noignorecase
Handle case-only file and directory renames in FastFetch on Windows
2 parents 1e748bb + 4748e73 commit 3d08ce4

9 files changed

Lines changed: 411 additions & 96 deletions

File tree

GVFS/FastFetch/CheckoutStage.cs

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -173,13 +173,25 @@ private void HandleAllDirectoryOperations()
173173
case DiffTreeResult.Operations.Add:
174174
try
175175
{
176-
Directory.CreateDirectory(absoluteTargetPath);
176+
if (treeOp.SourcePath != null)
177+
{
178+
this.ApplyCaseOnlyDirectoryRename(treeOp, absoluteTargetPath);
179+
}
180+
else
181+
{
182+
Directory.CreateDirectory(absoluteTargetPath);
183+
}
177184
}
178185
catch (Exception ex)
179186
{
180187
EventMetadata metadata = new EventMetadata();
181-
metadata.Add("Operation", "CreateDirectory");
188+
metadata.Add("Operation", treeOp.SourcePath != null ? "RenameDirectory" : "CreateDirectory");
182189
metadata.Add(nameof(treeOp.TargetPath), absoluteTargetPath);
190+
if (treeOp.SourcePath != null)
191+
{
192+
metadata.Add(nameof(treeOp.SourcePath), treeOp.SourcePath);
193+
}
194+
183195
this.tracer.RelatedError(metadata, ex.Message);
184196
this.HasFailures = true;
185197
}
@@ -222,6 +234,62 @@ private void HandleAllDirectoryOperations()
222234
}
223235
}
224236

237+
/// <summary>
238+
/// Apply a case-only directory rename produced by DiffHelper, where
239+
/// <paramref name="treeOp"/>.SourcePath carries the old casing and
240+
/// <paramref name="absoluteTargetPath"/> is the new (post-rename) absolute path.
241+
///
242+
/// Directory.Move throws IOException for case-only renames on Windows, so the
243+
/// rename is performed in two steps through a temporary name. If the second
244+
/// move fails the directory is moved back to the original casing so a retry
245+
/// sees a consistent working tree.
246+
///
247+
/// If the source directory is missing it usually means an outer parent rename
248+
/// has already moved the children into place (Windows preserves child casing
249+
/// through a parent rename when the children's tree SHAs were unchanged); the
250+
/// fallback creates the target directory so the operation is idempotent.
251+
/// Exceptions propagate to the caller's existing error handler.
252+
/// </summary>
253+
private void ApplyCaseOnlyDirectoryRename(DiffTreeResult treeOp, string absoluteTargetPath)
254+
{
255+
string absoluteSourcePath = Path.Combine(this.enlistment.WorkingDirectoryBackingRoot, treeOp.SourcePath);
256+
if (!Directory.Exists(absoluteSourcePath))
257+
{
258+
Directory.CreateDirectory(absoluteTargetPath);
259+
return;
260+
}
261+
262+
string trimmedSourcePath = absoluteSourcePath.TrimEnd(Path.DirectorySeparatorChar);
263+
string trimmedTargetPath = absoluteTargetPath.TrimEnd(Path.DirectorySeparatorChar);
264+
string tempPath = trimmedTargetPath + "_caseRename_" + Guid.NewGuid().ToString("N");
265+
266+
Directory.Move(trimmedSourcePath, tempPath);
267+
try
268+
{
269+
Directory.Move(tempPath, trimmedTargetPath);
270+
}
271+
catch
272+
{
273+
// The first move succeeded but the second failed. Try to restore the
274+
// original casing so a retry starts from a consistent state; if
275+
// restoration also fails, the outer catch will log the original
276+
// exception and the temp directory will be left behind for manual
277+
// recovery.
278+
if (Directory.Exists(tempPath) && !Directory.Exists(trimmedSourcePath))
279+
{
280+
try
281+
{
282+
Directory.Move(tempPath, trimmedSourcePath);
283+
}
284+
catch
285+
{
286+
}
287+
}
288+
289+
throw;
290+
}
291+
}
292+
225293
private void HandleAllFileDeleteOperations()
226294
{
227295
string path;

GVFS/GVFS.Common/Git/DiffTreeResult.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,18 @@ public enum Operations
3838
public ushort SourceMode { get; set; }
3939
public ushort TargetMode { get; set; }
4040

41+
/// <summary>
42+
/// Old-cased path of a case-only directory rename, set by DiffHelper when
43+
/// collapsing a Delete+Add pair under the case-insensitive comparer. When
44+
/// non-null the operation represents a rename from SourcePath to TargetPath
45+
/// and consumers (currently CheckoutStage) must rename the directory on
46+
/// disk instead of treating the operation as a plain Add. Always null for
47+
/// file operations, Modify, Delete, and non-rename Add entries. The setter
48+
/// is intentionally restricted to the assembly so only the parser can
49+
/// produce this annotation.
50+
/// </summary>
51+
public string SourcePath { get; internal set; }
52+
4153
public static DiffTreeResult ParseFromDiffTreeLine(string line)
4254
{
4355
if (string.IsNullOrEmpty(line))

0 commit comments

Comments
 (0)