Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 131 additions & 3 deletions src/NerdBank.GitVersioning/ManagedGit/GitRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,10 @@ public GitRepository(string workingDirectory, string gitDirectory, string common
this.objectPathBuffer[pathLengthInChars - 1] = '\0'; // Make sure to initialize with zeros

this.packs = new Lazy<ReadOnlyMemory<GitPack>>(this.LoadPacks);
}

// TODO: read from Git settings
// Read git configuration to determine case sensitivity
this.IgnoreCase = this.ReadIgnoreCaseFromConfig();
}

/// <summary>
/// Gets the encoding used by this Git repository.
Expand Down Expand Up @@ -518,7 +519,21 @@ public GitObjectId GetTreeEntry(GitObjectId treeId, ReadOnlySpan<byte> nodeName)
throw new GitException($"The tree {treeId} was not found in this repository.") { ErrorCode = GitException.ErrorCodes.ObjectNotFound };
}

return GitTreeStreamingReader.FindNode(treeStream, nodeName);
// Try case-sensitive search first
GitObjectId result = GitTreeStreamingReader.FindNode(treeStream, nodeName, ignoreCase: false);

// If not found and repository is configured for case-insensitive matching, try case-insensitive search
if (result == GitObjectId.Empty && this.IgnoreCase)
{
// Get a fresh stream for the second search since we can't reset position on ZLibStream
using Stream? treeStream2 = this.GetObjectBySha(treeId, "tree");
if (treeStream2 is not null)
{
result = GitTreeStreamingReader.FindNode(treeStream2, nodeName, ignoreCase: true);
}
}

return result;
}

/// <summary>
Expand Down Expand Up @@ -767,6 +782,119 @@ private static bool TryConvertHexStringToByteArray(string hexString, Span<byte>
return true;
}

/// <summary>
/// Attempts to read the core.ignorecase setting from a git config file.
/// </summary>
/// <param name="configPath">Path to the git config file.</param>
/// <param name="ignoreCase">The value of core.ignorecase if found.</param>
/// <returns>True if the setting was found and parsed successfully.</returns>
private static bool TryReadIgnoreCaseFromConfigFile(string configPath, out bool ignoreCase)
{
ignoreCase = false;
try
{
string[] lines = File.ReadAllLines(configPath);
bool inCoreSection = false;

foreach (string line in lines)
{
string trimmedLine = line.Trim();

// Check for section headers
if (trimmedLine.StartsWith("[") && trimmedLine.EndsWith("]"))
{
string sectionName = trimmedLine.Substring(1, trimmedLine.Length - 2).Trim();
inCoreSection = string.Equals(sectionName, "core", StringComparison.OrdinalIgnoreCase);
continue;
}

// If we're in the [core] section, look for ignorecase setting
if (inCoreSection && trimmedLine.Contains("="))
{
int equalIndex = trimmedLine.IndexOf('=');
string key = trimmedLine.Substring(0, equalIndex).Trim();
string value = trimmedLine.Substring(equalIndex + 1).Trim();

if (string.Equals(key, "ignorecase", StringComparison.OrdinalIgnoreCase))
{
ignoreCase = string.Equals(value, "true", StringComparison.OrdinalIgnoreCase);
return true;
}
}
}
}
catch
{
// Ignore errors and return false
}

return false;
}

/// <summary>
/// Gets the path to the global git config file.
/// </summary>
/// <returns>The path to the global config file, or null if not found.</returns>
private static string? GetGlobalConfigPath()
{
try
{
// Try common locations for global git config
string? homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
if (!string.IsNullOrEmpty(homeDir))
{
string gitConfigPath = Path.Combine(homeDir, ".gitconfig");
if (File.Exists(gitConfigPath))
{
return gitConfigPath;
}
}
}
catch
{
// Ignore errors
}

return null;
}

/// <summary>
/// Reads the core.ignorecase setting from git configuration.
/// </summary>
/// <returns>True if case should be ignored, false otherwise.</returns>
private bool ReadIgnoreCaseFromConfig()
{
try
{
// Try to read from .git/config first (repository-specific)
string repoConfigPath = Path.Combine(this.GitDirectory, "config");
if (File.Exists(repoConfigPath))
{
if (TryReadIgnoreCaseFromConfigFile(repoConfigPath, out bool ignoreCase))
{
return ignoreCase;
}
}

// Fall back to global config if repo config doesn't have the setting
string? globalConfigPath = GetGlobalConfigPath();
if (globalConfigPath is object && File.Exists(globalConfigPath))
{
if (TryReadIgnoreCaseFromConfigFile(globalConfigPath, out bool ignoreCase))
{
return ignoreCase;
}
}
}
catch
{
// If we can't read config, default to case-sensitive
}

// Default to case-sensitive (false) if no config found or error occurred
return false;
}

private bool TryGetObjectByPath(GitObjectId sha, string objectType, [NotNullWhen(true)] out Stream? value)
{
sha.CopyAsHex(0, 1, this.objectPathBuffer.AsSpan(this.ObjectDirectory.Length + 1, 2));
Expand Down
62 changes: 61 additions & 1 deletion src/NerdBank.GitVersioning/ManagedGit/GitTreeStreamingReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,24 @@ public class GitTreeStreamingReader
/// The <see cref="GitObjectId"/> of the requested node.
/// </returns>
public static GitObjectId FindNode(Stream stream, ReadOnlySpan<byte> name)
=> FindNode(stream, name, ignoreCase: false);

/// <summary>
/// Finds a specific node in a git tree.
/// </summary>
/// <param name="stream">
/// A <see cref="Stream"/> which represents the git tree.
/// </param>
/// <param name="name">
/// The name of the node to find, in it UTF-8 representation.
/// </param>
/// <param name="ignoreCase">
/// Whether to ignore case when matching node names.
/// </param>
/// <returns>
/// The <see cref="GitObjectId"/> of the requested node.
/// </returns>
public static GitObjectId FindNode(Stream stream, ReadOnlySpan<byte> name, bool ignoreCase)
{
byte[] buffer = ArrayPool<byte>.Shared.Rent((int)stream.Length);
var contents = new Span<byte>(buffer, 0, (int)stream.Length);
Expand All @@ -44,7 +62,11 @@ public static GitObjectId FindNode(Stream stream, ReadOnlySpan<byte> name)

Span<byte> currentName = contents.Slice(modeLength, fileNameEnds - modeLength);

if (currentName.SequenceEqual(name))
bool matches = ignoreCase
? currentName.SequenceEqual(name) || CompareIgnoreCase(currentName, name)
: currentName.SequenceEqual(name);

if (matches)
{
value = GitObjectId.Parse(contents.Slice(fileNameEnds + 1, 20));
break;
Expand All @@ -59,4 +81,42 @@ public static GitObjectId FindNode(Stream stream, ReadOnlySpan<byte> name)

return value;
}

/// <summary>
/// Compares two byte sequences case-insensitively (assuming UTF-8 encoding with ASCII characters).
/// </summary>
/// <param name="a">First byte sequence.</param>
/// <param name="b">Second byte sequence.</param>
/// <returns>True if the sequences are equal ignoring case.</returns>
private static bool CompareIgnoreCase(ReadOnlySpan<byte> a, ReadOnlySpan<byte> b)
{
if (a.Length != b.Length)
{
return false;
}

for (int i = 0; i < a.Length; i++)
{
byte aChar = a[i];
byte bChar = b[i];

// Convert to lowercase for ASCII characters
if (aChar >= 'A' && aChar <= 'Z')
{
aChar = (byte)(aChar + 32);
}

if (bChar >= 'A' && bChar <= 'Z')
{
bChar = (byte)(bChar + 32);
}

if (aChar != bChar)
{
return false;
}
}

return true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,37 @@ public void FindTreeTest()
Assert.Equal("ec8e91fc4ad13d6a214584330f26d7a05495c8cc", blobObjectId.ToString());
}
}

[Fact]
public void FindBlobCaseInsensitiveTest()
{
using (Stream stream = TestUtilities.GetEmbeddedResource(@"ManagedGit\tree.bin"))
{
// Try to find "version.json" with different casing
GitObjectId blobObjectId = GitTreeStreamingReader.FindNode(stream, Encoding.UTF8.GetBytes("VERSION.JSON"), ignoreCase: true);
Assert.Equal("59552a5eed6779aa4e5bb4dc96e80f36bb6e7380", blobObjectId.ToString());
}
}

[Fact]
public void FindBlobCaseSensitiveFailsWithDifferentCasing()
{
using (Stream stream = TestUtilities.GetEmbeddedResource(@"ManagedGit\tree.bin"))
{
// Try to find "version.json" with different casing using case-sensitive search
GitObjectId blobObjectId = GitTreeStreamingReader.FindNode(stream, Encoding.UTF8.GetBytes("VERSION.JSON"), ignoreCase: false);
Assert.Equal(GitObjectId.Empty, blobObjectId);
}
}

[Fact]
public void FindTreeCaseInsensitiveTest()
{
using (Stream stream = TestUtilities.GetEmbeddedResource(@"ManagedGit\tree.bin"))
{
// Try to find "tools" with different casing
GitObjectId blobObjectId = GitTreeStreamingReader.FindNode(stream, Encoding.UTF8.GetBytes("TOOLS"), ignoreCase: true);
Assert.Equal("ec8e91fc4ad13d6a214584330f26d7a05495c8cc", blobObjectId.ToString());
}
}
}
76 changes: 76 additions & 0 deletions test/Nerdbank.GitVersioning.Tests/VersionFileTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,82 @@ public void GetVersion_ReadNuGetPackageVersionSettings_Precision(VersionOptions.
Assert.Equal(precision, versionOptions.NuGetPackageVersion.Precision);
}

[Fact]
public void GetVersion_CaseInsensitivePathMatching_Simple()
{
// Simple test to debug the issue
this.InitializeSourceControl();

// Create a version.json file using the standard test method
this.WriteVersionFile(new VersionOptions { Version = new SemanticVersion("1.0.0") });

// Test reading from the root - this should work
VersionOptions actualVersionOptions = this.GetVersionOptions();
Assert.NotNull(actualVersionOptions);
Assert.Equal("1.0.0", actualVersionOptions.Version.ToString());
}

[Fact]
public void GetVersion_CaseInsensitivePathMatching()
{
// This test verifies that when a project's repo-relative path case doesn't match
// what git records, we still find the version.json file through case-insensitive fallback.
this.InitializeSourceControl();

// Create a version.json file in a subdirectory using the standard method
string subDirPath = "MyProject";
this.WriteVersionFile(
new VersionOptions
{
Version = new SemanticVersion("1.2.3"),
VersionHeightOffset = 10,
},
subDirPath);

// Configure the git repository for case-insensitive matching
// This simulates a Windows/macOS environment where the filesystem is case-insensitive
string gitConfigPath = Path.Combine(this.RepoPath, ".git", "config");
string configContent = File.ReadAllText(gitConfigPath);
if (!configContent.Contains("[core]"))
{
configContent += "\n[core]\n\tignorecase = true\n";
}
else if (!configContent.Contains("ignorecase"))
{
configContent = configContent.Replace("[core]", "[core]\n\tignorecase = true");
}

File.WriteAllText(gitConfigPath, configContent);

// First test: Get the version from the actual path with correct casing
VersionOptions actualVersionOptions = this.GetVersionOptions(subDirPath);

// Verify we found the version file and it has the expected version
Assert.NotNull(actualVersionOptions);
Assert.Equal("1.2.3", actualVersionOptions.Version.ToString());
Assert.Equal(10, actualVersionOptions.VersionHeightOffset);

// Second test: Now test with different casing in the path - this should work
// when core.ignorecase is true, because GetTreeEntry should fall back
// to case-insensitive matching
VersionOptions actualVersionOptionsWithDifferentCase = this.GetVersionOptions("myproject");

// This should also find the version file despite the case difference
// NOTE: This currently only works for the managed implementation, not LibGit2
if (this is VersionFileManagedTests)
{
Assert.NotNull(actualVersionOptionsWithDifferentCase);
Assert.Equal("1.2.3", actualVersionOptionsWithDifferentCase.Version.ToString());
Assert.Equal(10, actualVersionOptionsWithDifferentCase.VersionHeightOffset);
}
else
{
// LibGit2 implementation doesn't yet support case-insensitive fallback
// This test documents the current limitation
Assert.Null(actualVersionOptionsWithDifferentCase);
}
}

private void AssertPathHasVersion(string committish, string absolutePath, VersionOptions expected)
{
VersionOptions actual = this.GetVersionOptions(absolutePath, committish);
Expand Down
Loading