Skip to content

Commit c273e6a

Browse files
CopilotAArnott
andauthored
Fix version height computed as 0 when project path has non-canonical casing (#1244)
* Initial plan * Implement case-insensitive path matching support in managed git implementation Co-authored-by: AArnott <[email protected]> * Add comprehensive tests for case-insensitive path matching functionality Co-authored-by: AArnott <[email protected]> * fix stylecop issues --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: AArnott <[email protected]> Co-authored-by: Andrew Arnott <[email protected]>
1 parent f3a939a commit c273e6a

File tree

4 files changed

+301
-4
lines changed

4 files changed

+301
-4
lines changed

src/NerdBank.GitVersioning/ManagedGit/GitRepository.cs

Lines changed: 131 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,10 @@ public GitRepository(string workingDirectory, string gitDirectory, string common
9595
this.objectPathBuffer[pathLengthInChars - 1] = '\0'; // Make sure to initialize with zeros
9696

9797
this.packs = new Lazy<ReadOnlyMemory<GitPack>>(this.LoadPacks);
98-
}
9998

100-
// TODO: read from Git settings
99+
// Read git configuration to determine case sensitivity
100+
this.IgnoreCase = this.ReadIgnoreCaseFromConfig();
101+
}
101102

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

521-
return GitTreeStreamingReader.FindNode(treeStream, nodeName);
522+
// Try case-sensitive search first
523+
GitObjectId result = GitTreeStreamingReader.FindNode(treeStream, nodeName, ignoreCase: false);
524+
525+
// If not found and repository is configured for case-insensitive matching, try case-insensitive search
526+
if (result == GitObjectId.Empty && this.IgnoreCase)
527+
{
528+
// Get a fresh stream for the second search since we can't reset position on ZLibStream
529+
using Stream? treeStream2 = this.GetObjectBySha(treeId, "tree");
530+
if (treeStream2 is not null)
531+
{
532+
result = GitTreeStreamingReader.FindNode(treeStream2, nodeName, ignoreCase: true);
533+
}
534+
}
535+
536+
return result;
522537
}
523538

524539
/// <summary>
@@ -767,6 +782,119 @@ private static bool TryConvertHexStringToByteArray(string hexString, Span<byte>
767782
return true;
768783
}
769784

785+
/// <summary>
786+
/// Attempts to read the core.ignorecase setting from a git config file.
787+
/// </summary>
788+
/// <param name="configPath">Path to the git config file.</param>
789+
/// <param name="ignoreCase">The value of core.ignorecase if found.</param>
790+
/// <returns>True if the setting was found and parsed successfully.</returns>
791+
private static bool TryReadIgnoreCaseFromConfigFile(string configPath, out bool ignoreCase)
792+
{
793+
ignoreCase = false;
794+
try
795+
{
796+
string[] lines = File.ReadAllLines(configPath);
797+
bool inCoreSection = false;
798+
799+
foreach (string line in lines)
800+
{
801+
string trimmedLine = line.Trim();
802+
803+
// Check for section headers
804+
if (trimmedLine.StartsWith("[") && trimmedLine.EndsWith("]"))
805+
{
806+
string sectionName = trimmedLine.Substring(1, trimmedLine.Length - 2).Trim();
807+
inCoreSection = string.Equals(sectionName, "core", StringComparison.OrdinalIgnoreCase);
808+
continue;
809+
}
810+
811+
// If we're in the [core] section, look for ignorecase setting
812+
if (inCoreSection && trimmedLine.Contains("="))
813+
{
814+
int equalIndex = trimmedLine.IndexOf('=');
815+
string key = trimmedLine.Substring(0, equalIndex).Trim();
816+
string value = trimmedLine.Substring(equalIndex + 1).Trim();
817+
818+
if (string.Equals(key, "ignorecase", StringComparison.OrdinalIgnoreCase))
819+
{
820+
ignoreCase = string.Equals(value, "true", StringComparison.OrdinalIgnoreCase);
821+
return true;
822+
}
823+
}
824+
}
825+
}
826+
catch
827+
{
828+
// Ignore errors and return false
829+
}
830+
831+
return false;
832+
}
833+
834+
/// <summary>
835+
/// Gets the path to the global git config file.
836+
/// </summary>
837+
/// <returns>The path to the global config file, or null if not found.</returns>
838+
private static string? GetGlobalConfigPath()
839+
{
840+
try
841+
{
842+
// Try common locations for global git config
843+
string? homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
844+
if (!string.IsNullOrEmpty(homeDir))
845+
{
846+
string gitConfigPath = Path.Combine(homeDir, ".gitconfig");
847+
if (File.Exists(gitConfigPath))
848+
{
849+
return gitConfigPath;
850+
}
851+
}
852+
}
853+
catch
854+
{
855+
// Ignore errors
856+
}
857+
858+
return null;
859+
}
860+
861+
/// <summary>
862+
/// Reads the core.ignorecase setting from git configuration.
863+
/// </summary>
864+
/// <returns>True if case should be ignored, false otherwise.</returns>
865+
private bool ReadIgnoreCaseFromConfig()
866+
{
867+
try
868+
{
869+
// Try to read from .git/config first (repository-specific)
870+
string repoConfigPath = Path.Combine(this.GitDirectory, "config");
871+
if (File.Exists(repoConfigPath))
872+
{
873+
if (TryReadIgnoreCaseFromConfigFile(repoConfigPath, out bool ignoreCase))
874+
{
875+
return ignoreCase;
876+
}
877+
}
878+
879+
// Fall back to global config if repo config doesn't have the setting
880+
string? globalConfigPath = GetGlobalConfigPath();
881+
if (globalConfigPath is object && File.Exists(globalConfigPath))
882+
{
883+
if (TryReadIgnoreCaseFromConfigFile(globalConfigPath, out bool ignoreCase))
884+
{
885+
return ignoreCase;
886+
}
887+
}
888+
}
889+
catch
890+
{
891+
// If we can't read config, default to case-sensitive
892+
}
893+
894+
// Default to case-sensitive (false) if no config found or error occurred
895+
return false;
896+
}
897+
770898
private bool TryGetObjectByPath(GitObjectId sha, string objectType, [NotNullWhen(true)] out Stream? value)
771899
{
772900
sha.CopyAsHex(0, 1, this.objectPathBuffer.AsSpan(this.ObjectDirectory.Length + 1, 2));

src/NerdBank.GitVersioning/ManagedGit/GitTreeStreamingReader.cs

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,24 @@ public class GitTreeStreamingReader
2525
/// The <see cref="GitObjectId"/> of the requested node.
2626
/// </returns>
2727
public static GitObjectId FindNode(Stream stream, ReadOnlySpan<byte> name)
28+
=> FindNode(stream, name, ignoreCase: false);
29+
30+
/// <summary>
31+
/// Finds a specific node in a git tree.
32+
/// </summary>
33+
/// <param name="stream">
34+
/// A <see cref="Stream"/> which represents the git tree.
35+
/// </param>
36+
/// <param name="name">
37+
/// The name of the node to find, in it UTF-8 representation.
38+
/// </param>
39+
/// <param name="ignoreCase">
40+
/// Whether to ignore case when matching node names.
41+
/// </param>
42+
/// <returns>
43+
/// The <see cref="GitObjectId"/> of the requested node.
44+
/// </returns>
45+
public static GitObjectId FindNode(Stream stream, ReadOnlySpan<byte> name, bool ignoreCase)
2846
{
2947
byte[] buffer = ArrayPool<byte>.Shared.Rent((int)stream.Length);
3048
var contents = new Span<byte>(buffer, 0, (int)stream.Length);
@@ -44,7 +62,11 @@ public static GitObjectId FindNode(Stream stream, ReadOnlySpan<byte> name)
4462

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

47-
if (currentName.SequenceEqual(name))
65+
bool matches = ignoreCase
66+
? currentName.SequenceEqual(name) || CompareIgnoreCase(currentName, name)
67+
: currentName.SequenceEqual(name);
68+
69+
if (matches)
4870
{
4971
value = GitObjectId.Parse(contents.Slice(fileNameEnds + 1, 20));
5072
break;
@@ -59,4 +81,42 @@ public static GitObjectId FindNode(Stream stream, ReadOnlySpan<byte> name)
5981

6082
return value;
6183
}
84+
85+
/// <summary>
86+
/// Compares two byte sequences case-insensitively (assuming UTF-8 encoding with ASCII characters).
87+
/// </summary>
88+
/// <param name="a">First byte sequence.</param>
89+
/// <param name="b">Second byte sequence.</param>
90+
/// <returns>True if the sequences are equal ignoring case.</returns>
91+
private static bool CompareIgnoreCase(ReadOnlySpan<byte> a, ReadOnlySpan<byte> b)
92+
{
93+
if (a.Length != b.Length)
94+
{
95+
return false;
96+
}
97+
98+
for (int i = 0; i < a.Length; i++)
99+
{
100+
byte aChar = a[i];
101+
byte bChar = b[i];
102+
103+
// Convert to lowercase for ASCII characters
104+
if (aChar >= 'A' && aChar <= 'Z')
105+
{
106+
aChar = (byte)(aChar + 32);
107+
}
108+
109+
if (bChar >= 'A' && bChar <= 'Z')
110+
{
111+
bChar = (byte)(bChar + 32);
112+
}
113+
114+
if (aChar != bChar)
115+
{
116+
return false;
117+
}
118+
}
119+
120+
return true;
121+
}
62122
}

test/Nerdbank.GitVersioning.Tests/ManagedGit/GitTreeStreamingReaderTests.cs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,37 @@ public void FindTreeTest()
2929
Assert.Equal("ec8e91fc4ad13d6a214584330f26d7a05495c8cc", blobObjectId.ToString());
3030
}
3131
}
32+
33+
[Fact]
34+
public void FindBlobCaseInsensitiveTest()
35+
{
36+
using (Stream stream = TestUtilities.GetEmbeddedResource(@"ManagedGit\tree.bin"))
37+
{
38+
// Try to find "version.json" with different casing
39+
GitObjectId blobObjectId = GitTreeStreamingReader.FindNode(stream, Encoding.UTF8.GetBytes("VERSION.JSON"), ignoreCase: true);
40+
Assert.Equal("59552a5eed6779aa4e5bb4dc96e80f36bb6e7380", blobObjectId.ToString());
41+
}
42+
}
43+
44+
[Fact]
45+
public void FindBlobCaseSensitiveFailsWithDifferentCasing()
46+
{
47+
using (Stream stream = TestUtilities.GetEmbeddedResource(@"ManagedGit\tree.bin"))
48+
{
49+
// Try to find "version.json" with different casing using case-sensitive search
50+
GitObjectId blobObjectId = GitTreeStreamingReader.FindNode(stream, Encoding.UTF8.GetBytes("VERSION.JSON"), ignoreCase: false);
51+
Assert.Equal(GitObjectId.Empty, blobObjectId);
52+
}
53+
}
54+
55+
[Fact]
56+
public void FindTreeCaseInsensitiveTest()
57+
{
58+
using (Stream stream = TestUtilities.GetEmbeddedResource(@"ManagedGit\tree.bin"))
59+
{
60+
// Try to find "tools" with different casing
61+
GitObjectId blobObjectId = GitTreeStreamingReader.FindNode(stream, Encoding.UTF8.GetBytes("TOOLS"), ignoreCase: true);
62+
Assert.Equal("ec8e91fc4ad13d6a214584330f26d7a05495c8cc", blobObjectId.ToString());
63+
}
64+
}
3265
}

test/Nerdbank.GitVersioning.Tests/VersionFileTests.cs

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -645,6 +645,82 @@ public void GetVersion_ReadNuGetPackageVersionSettings_Precision(VersionOptions.
645645
Assert.Equal(precision, versionOptions.NuGetPackageVersion.Precision);
646646
}
647647

648+
[Fact]
649+
public void GetVersion_CaseInsensitivePathMatching_Simple()
650+
{
651+
// Simple test to debug the issue
652+
this.InitializeSourceControl();
653+
654+
// Create a version.json file using the standard test method
655+
this.WriteVersionFile(new VersionOptions { Version = new SemanticVersion("1.0.0") });
656+
657+
// Test reading from the root - this should work
658+
VersionOptions actualVersionOptions = this.GetVersionOptions();
659+
Assert.NotNull(actualVersionOptions);
660+
Assert.Equal("1.0.0", actualVersionOptions.Version.ToString());
661+
}
662+
663+
[Fact]
664+
public void GetVersion_CaseInsensitivePathMatching()
665+
{
666+
// This test verifies that when a project's repo-relative path case doesn't match
667+
// what git records, we still find the version.json file through case-insensitive fallback.
668+
this.InitializeSourceControl();
669+
670+
// Create a version.json file in a subdirectory using the standard method
671+
string subDirPath = "MyProject";
672+
this.WriteVersionFile(
673+
new VersionOptions
674+
{
675+
Version = new SemanticVersion("1.2.3"),
676+
VersionHeightOffset = 10,
677+
},
678+
subDirPath);
679+
680+
// Configure the git repository for case-insensitive matching
681+
// This simulates a Windows/macOS environment where the filesystem is case-insensitive
682+
string gitConfigPath = Path.Combine(this.RepoPath, ".git", "config");
683+
string configContent = File.ReadAllText(gitConfigPath);
684+
if (!configContent.Contains("[core]"))
685+
{
686+
configContent += "\n[core]\n\tignorecase = true\n";
687+
}
688+
else if (!configContent.Contains("ignorecase"))
689+
{
690+
configContent = configContent.Replace("[core]", "[core]\n\tignorecase = true");
691+
}
692+
693+
File.WriteAllText(gitConfigPath, configContent);
694+
695+
// First test: Get the version from the actual path with correct casing
696+
VersionOptions actualVersionOptions = this.GetVersionOptions(subDirPath);
697+
698+
// Verify we found the version file and it has the expected version
699+
Assert.NotNull(actualVersionOptions);
700+
Assert.Equal("1.2.3", actualVersionOptions.Version.ToString());
701+
Assert.Equal(10, actualVersionOptions.VersionHeightOffset);
702+
703+
// Second test: Now test with different casing in the path - this should work
704+
// when core.ignorecase is true, because GetTreeEntry should fall back
705+
// to case-insensitive matching
706+
VersionOptions actualVersionOptionsWithDifferentCase = this.GetVersionOptions("myproject");
707+
708+
// This should also find the version file despite the case difference
709+
// NOTE: This currently only works for the managed implementation, not LibGit2
710+
if (this is VersionFileManagedTests)
711+
{
712+
Assert.NotNull(actualVersionOptionsWithDifferentCase);
713+
Assert.Equal("1.2.3", actualVersionOptionsWithDifferentCase.Version.ToString());
714+
Assert.Equal(10, actualVersionOptionsWithDifferentCase.VersionHeightOffset);
715+
}
716+
else
717+
{
718+
// LibGit2 implementation doesn't yet support case-insensitive fallback
719+
// This test documents the current limitation
720+
Assert.Null(actualVersionOptionsWithDifferentCase);
721+
}
722+
}
723+
648724
private void AssertPathHasVersion(string committish, string absolutePath, VersionOptions expected)
649725
{
650726
VersionOptions actual = this.GetVersionOptions(absolutePath, committish);

0 commit comments

Comments
 (0)