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
2 changes: 1 addition & 1 deletion .github/workflows/_build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ jobs:
run: |
./Scripts/UpdateSubmodules.ps1

- uses: actions/cache@v2
- uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.config') }}
Expand Down
77 changes: 77 additions & 0 deletions GitTreeFilter.Core.Tests/DetachedHeadChangesetTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
using FluentAssertions;
using FluentAssertions.Execution;
using GitTreeFilter.Core.Exceptions;
using GitTreeFilter.Core.Tests.Test;
using GitTreeFilter.Core.Tests.Test.Repositories;
using GitTreeFilter.Core.Tests.Test.Repositories.Basic;
using GitTreeFilter.Core.Tests.Test.Repositories.Merge;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System.Collections.Immutable;
using System.Linq;

namespace GitTreeFilter.Core.Tests
{
[TestClass]
[TestCategory("SolutionRepository.Changeset")]
public class DetachedHeadChangesetTest : SolutionRepositoryTest
{
[DataTestMethod]
[AllRepositoriesData]
public void DoesNotThrowWhenHeadIsDetached(ITestRepository testRepository)
{
// given
var solutionRepository = CreateSolutionRepository(testRepository);
ReferenceObject = testRepository.Head;

// when & then
RepositoryState.WithDetachedHead(testRepository, () =>
{
solutionRepository.Invoking(x => x.Changeset)
.Should()
.NotThrow<HeadNotFoundException>();
});
}

[DataTestMethod]
[BasicRepositoryChangesetDataSource]
public void BasicRepositoryChangesetWithDetachedHead(
ITestRepository testRepository,
TestComparisonConfig comparisonConfig,
IImmutableSet<string> changedFilesPaths)
{
AssertChangesetUnderDetachedHead(testRepository, comparisonConfig, changedFilesPaths);
}

[DataTestMethod]
[MergeRepositoryChangesetDataSource]
public void MergeRepositoryChangesetWithDetachedHead(
ITestRepository testRepository,
TestComparisonConfig comparisonConfig,
IImmutableSet<string> changedFilesPaths)
{
AssertChangesetUnderDetachedHead(testRepository, comparisonConfig, changedFilesPaths);
}

private void AssertChangesetUnderDetachedHead(
ITestRepository testRepository,
TestComparisonConfig comparisonConfig,
IImmutableSet<string> changedFilesPaths)
{
// given
var solutionRepository = CreateSolutionRepository(testRepository);
SetComparisonConfig(comparisonConfig);

// when & then
RepositoryState.WithDetachedHead(testRepository, () =>
{
var changeset = solutionRepository.Changeset;

using (new AssertionScope())
{
changeset.Should().NotBeNull();
changeset.Select(x => x.AbsoluteFilePath).Should().BeEquivalentTo(changedFilesPaths);
}
});
}
}
}
78 changes: 78 additions & 0 deletions GitTreeFilter.Core.Tests/GitRepositoryDiscoveryTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
using System;
using System.IO;
using FluentAssertions;
using GitTreeFilter.Core;
using GitTreeFilter.Core.Tests.Test.Repositories;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace GitTreeFilter.Core.Tests
{
[TestClass]
[TestCategory("GitRepositoryDiscovery")]
public class GitRepositoryDiscoveryTest
{
[TestMethod]
public void DiscoversRepositoryRootWhenGitIsAFile()
{
// given a working directory whose ".git" is a file (submodule/worktree pointer)
var repositoryRoot = TestRepositories.Basic.Solution.SolutionPath;
File.Exists(Path.Combine(repositoryRoot, ".git"))
.Should().BeTrue("this fixture must reproduce the worktree/submodule '.git is a file' case");

// when
var discovered = GitRepositoryDiscovery.TryGetRepositoryRoot(repositoryRoot, out var root);

// then
discovered.Should().BeTrue();
NormalizeDirectory(root).Should().Be(NormalizeDirectory(repositoryRoot));
}

[TestMethod]
public void DiscoversRepositoryRootFromNestedSubdirectory()
{
// given a nested subdirectory inside the repository working directory
var repositoryRoot = TestRepositories.Basic.Solution.SolutionPath;
var nested = Path.Combine(repositoryRoot, "nested-" + Guid.NewGuid().ToString("N"), "deeper");
Directory.CreateDirectory(nested);

try
{
// when
var discovered = GitRepositoryDiscovery.TryGetRepositoryRoot(nested, out var root);

// then
discovered.Should().BeTrue();
NormalizeDirectory(root).Should().Be(NormalizeDirectory(repositoryRoot));
}
finally
{
Directory.Delete(Path.GetDirectoryName(nested), recursive: true);
}
}

[TestMethod]
public void ReturnsFalseWhenPathIsNotInsideRepository()
{
// given a directory that is not inside any git repository
var outside = Path.Combine(Path.GetTempPath(), "gtf-not-a-repo-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(outside);

try
{
// when
var discovered = GitRepositoryDiscovery.TryGetRepositoryRoot(outside, out var root);

// then
discovered.Should().BeFalse();
root.Should().BeNull();
}
finally
{
Directory.Delete(outside, recursive: true);
}
}

private static string NormalizeDirectory(string path) =>
Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
}
}
166 changes: 166 additions & 0 deletions GitTreeFilter.Core.Tests/ModernIndexChangesetTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
using System;
using System.Diagnostics;
using System.IO;
using FluentAssertions;
using GitTreeFilter.Core.Models;
using GitTreeFilter.Core.Tests.Test;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace GitTreeFilter.Core.Tests
{
/// <summary>
/// Regression tests for the "invalid data in index - calculated checksum does not
/// match expected" failure. Modern git (2.x with feature.manyFiles / untracked cache)
/// writes index extensions that the previously bundled libgit2 could not parse, which
/// made <see cref="SolutionRepository.Changeset"/> throw and left the Solution Explorer
/// list empty ("won't budge").
/// </summary>
[TestClass]
[TestCategory("SolutionRepository.Changeset")]
public class ModernIndexChangesetTest
{
[TestMethod]
public void ChangesetDoesNotThrowForModernGitIndex()
{
// given a repository whose on-disk index was written by a modern git with
// feature.manyFiles enabled (index v4 + untracked-cache extension). This is the
// corner case that older libgit2 rejected with a checksum error.
if (!IsGitAvailable())
{
Assert.Inconclusive("git CLI is required to materialize a modern index for this test.");
}

var repositoryPath = CreateModernIndexRepository(out var headSha);

try
{
var comparisonConfig = new TestComparisonConfig
{
ReferenceObject = new GitCommit(new GitCommitObject(headSha, "HEAD"))
};

var solutionRepository = SolutionRepositoryFactory.CreateSolutionRepository(
new GitSolution(repositoryPath), comparisonConfig);

// when & then: reading the changeset must parse the index without throwing.
solutionRepository.Invoking(x => x.Changeset)
.Should()
.NotThrow("modern git index extensions must be parseable");

solutionRepository.Changeset.Should().NotBeNull();
}
finally
{
ForceDeleteDirectory(repositoryPath);
}
}

/// <summary>
/// Builds a throwaway git repository whose index is large enough and configured such
/// that git writes the modern extensions (index v4 + untracked cache) that reproduce
/// the checksum-parsing failure. Returns the working directory path.
/// </summary>
private static string CreateModernIndexRepository(out string headSha)
{
var path = Path.Combine(Path.GetTempPath(), "gtf-modern-index-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(path);

Git(path, "init");
// feature.manyFiles turns on index.version=4, index.threads and core.untrackedCache,
// i.e. exactly the extensions that older libgit2 mis-parsed.
Git(path, "config feature.manyFiles true");
Git(path, "config core.autocrlf false");
Git(path, "config user.email test@example.com");
Git(path, "config user.name test");

// Enough files (spread across directories) to push the index past the size at which
// the extension mis-parse manifests as a checksum mismatch.
for (var i = 0; i < 500; i++)
{
var directory = Path.Combine(path, "dir" + (i % 16));
Directory.CreateDirectory(directory);
File.WriteAllText(Path.Combine(directory, "f" + i + ".txt"), "content " + i);
}

Git(path, "add .");
Git(path, "commit -m init");
// Force git to (re)write the untracked-cache extension into the index.
Git(path, "status --short");

headSha = Git(path, "rev-parse HEAD").Trim();
return path;
}

private static bool IsGitAvailable()
{
try
{
RunProcess(Environment.CurrentDirectory, "git", "--version");
return true;
}
catch
{
return false;
}
}

private static string Git(string workingDirectory, string arguments) =>
RunProcess(workingDirectory, "git", arguments);

private static string RunProcess(string workingDirectory, string fileName, string arguments)
{
var startInfo = new ProcessStartInfo
{
FileName = fileName,
Arguments = arguments,
WorkingDirectory = workingDirectory,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};

using var process = Process.Start(startInfo);
var output = process.StandardOutput.ReadToEnd();
var error = process.StandardError.ReadToEnd();
process.WaitForExit();

if (process.ExitCode != 0)
{
throw new InvalidOperationException(
$"'{fileName} {arguments}' failed with exit code {process.ExitCode}: {error}");
}

return output;
}

private static void ForceDeleteDirectory(string path)
{
if (!Directory.Exists(path))
{
return;
}

foreach (var file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories))
{
try
{
File.SetAttributes(file, FileAttributes.Normal);
}
catch
{
// best effort
}
}

try
{
Directory.Delete(path, recursive: true);
}
catch
{
// best effort cleanup of a temp directory
}
}
}
}
Loading
Loading