Skip to content
Open
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
13 changes: 13 additions & 0 deletions GitIntegration.Test/GitRepositoryVerbTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,19 @@
Assert.IsTrue(isCloned);
}

[TestMethod]
public async Task IsClonedReportsFalseForADirectoryBelowAWorkingTreeRootAsync()
{
// --show-cdup prints the way up to the root, so a subdirectory of some other repository's
// working tree is inside a working tree but is not a clone.
ScriptedGitProcessRunner runner = new ScriptedGitProcessRunner().Then(standardOutput: "true\n../\n");
GitRepository repository = RepositoryOn(runner);

bool isCloned = await repository.IsClonedAsync(TestContext.CancellationTokenSource.Token).ConfigureAwait(false);

Check warning on line 146 in GitIntegration.Test/GitRepositoryVerbTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'TestContext.CancellationToken' instead of 'TestContext.CancellationTokenSource.Token'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_GitIntegration&issues=AaEgQFEHg-0VKnnF0GDw&open=AaEgQFEHg-0VKnnF0GDw&pullRequest=204

Assert.IsFalse(isCloned);
}

[TestMethod]
public async Task IsClonedReportsFalseForAPathThatIsNotAWorkingTreeAsync()
{
Expand Down
65 changes: 65 additions & 0 deletions GitIntegration.Test/Integration/GitRoundTripTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,71 @@
Assert.IsFalse(init.AlreadyExisted);
}

[TestMethod]
public async Task IsClonedReportsADirectoryInsideAnotherRepositoryAsNotClonedAsync()
{
// git run in either directory would act on the outer repository, which is exactly why
// "clone if not cloned" must not take them for clones.
CancellationToken cancellationToken = TestContext.CancellationTokenSource.Token;

Check warning on line 160 in GitIntegration.Test/Integration/GitRoundTripTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'TestContext.CancellationToken' instead of 'TestContext.CancellationTokenSource.Token'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_GitIntegration&issues=AaEgQE_Lg-0VKnnF0GDs&open=AaEgQE_Lg-0VKnnF0GDs&pullRequest=204
await IntegrationGitFixture.RequireGitAsync(cancellationToken).ConfigureAwait(false);

using TemporaryRepository temporary = new();
GitRepository outer = await InitializeAsync(temporary, cancellationToken).ConfigureAwait(false);

string empty = Path.Join(temporary.RootPath, "SomeRepo");
_ = Directory.CreateDirectory(empty);
temporary.WriteFile("sub/a.txt", "one\n");

GitRepository emptyDirectory = new() { LocalPath = empty.As<AbsoluteDirectoryPath>(), ProcessRunner = outer.ProcessRunner };
GitRepository subdirectory = new() { LocalPath = Path.Join(temporary.RootPath, "sub").As<AbsoluteDirectoryPath>(), ProcessRunner = outer.ProcessRunner };

Assert.IsFalse(await emptyDirectory.IsClonedAsync(cancellationToken).ConfigureAwait(false), "empty directory");
Assert.IsFalse(await subdirectory.IsClonedAsync(cancellationToken).ConfigureAwait(false), "non-empty subdirectory");
Assert.IsTrue(await outer.IsClonedAsync(cancellationToken).ConfigureAwait(false), "the outer repository itself");
}

[TestMethod]
public async Task IsClonedReportsASeparateGitDirCheckoutAsClonedAsync()
{
CancellationToken cancellationToken = TestContext.CancellationTokenSource.Token;

Check warning on line 181 in GitIntegration.Test/Integration/GitRoundTripTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'TestContext.CancellationToken' instead of 'TestContext.CancellationTokenSource.Token'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_GitIntegration&issues=AaEgQE_Lg-0VKnnF0GDt&open=AaEgQE_Lg-0VKnnF0GDt&pullRequest=204
await IntegrationGitFixture.RequireGitAsync(cancellationToken).ConfigureAwait(false);

using TemporaryRepository temporary = new();
using TemporaryRepository separateGitDir = new();
GitRepository repository = await InitializeAsync(temporary, cancellationToken).ConfigureAwait(false);

string gitDir = Path.Join(separateGitDir.RootPath, "sg");
_ = await new GitTextBuilder(repository.ProcessRunner!, temporary.Root, "init", "--separate-git-dir", gitDir, "s")
.ExecuteAsync(cancellationToken).ConfigureAwait(false);

GitRepository checkout = new() { LocalPath = Path.Join(temporary.RootPath, "s").As<AbsoluteDirectoryPath>(), ProcessRunner = repository.ProcessRunner };

Assert.IsTrue(await checkout.IsClonedAsync(cancellationToken).ConfigureAwait(false));
}

[TestMethod]
public async Task IsClonedReportsALinkedWorktreeAsClonedAsync()
{
CancellationToken cancellationToken = TestContext.CancellationTokenSource.Token;
await IntegrationGitFixture.RequireGitAsync(cancellationToken).ConfigureAwait(false);

using TemporaryRepository temporary = new();
using TemporaryRepository worktreeParent = new();
GitRepository repository = await InitializeAsync(temporary, cancellationToken).ConfigureAwait(false);

temporary.WriteFile("a.txt", "one\n");
_ = await repository.Add().All().ExecuteAsync(cancellationToken).ConfigureAwait(false);
_ = await repository.Commit("c1".As<GitCommitMessage>()).ExecuteAsync(cancellationToken).ConfigureAwait(false);

string worktree = Path.Join(worktreeParent.RootPath, "wt");
_ = await new GitTextBuilder(repository.ProcessRunner!, repository.LocalPath, "worktree", "add", worktree)
.ExecuteAsync(cancellationToken).ConfigureAwait(false);

GitRepository linked = new() { LocalPath = worktree.As<AbsoluteDirectoryPath>(), ProcessRunner = repository.ProcessRunner };

Assert.IsTrue(await linked.IsClonedAsync(cancellationToken).ConfigureAwait(false));
}

[TestMethod]
public async Task AddAndCommitProduceAReadableCommitAsync()
{
Expand Down Expand Up @@ -726,7 +791,7 @@
[TestMethod]
public async Task CloneReproducesTheSourceHistoryAsync()
{
CancellationToken cancellationToken = TestContext.CancellationTokenSource.Token;

Check warning on line 794 in GitIntegration.Test/Integration/GitRoundTripTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'TestContext.CancellationToken' instead of 'TestContext.CancellationTokenSource.Token'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_GitIntegration&issues=AaEgQE_Lg-0VKnnF0GDu&open=AaEgQE_Lg-0VKnnF0GDu&pullRequest=204
await IntegrationGitFixture.RequireGitAsync(cancellationToken).ConfigureAwait(false);

using TemporaryRepository source = new();
Expand Down
22 changes: 22 additions & 0 deletions GitIntegration.Test/Integration/GitSubmoduleTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace ktsu.GitIntegration.Test;

using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
Expand Down Expand Up @@ -115,6 +116,27 @@
Assert.IsNotNull(submodules[0].Describe);
}

[TestMethod]
public async Task IsClonedReportsASubmoduleCheckoutButNotTheDirectoryHoldingItAsync()
{
CancellationToken cancellationToken = TestContext.CancellationTokenSource.Token;

Check warning on line 122 in GitIntegration.Test/Integration/GitSubmoduleTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'TestContext.CancellationToken' instead of 'TestContext.CancellationTokenSource.Token'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_GitIntegration&issues=AaEgQFCyg-0VKnnF0GDv&open=AaEgQFCyg-0VKnnF0GDv&pullRequest=204
await IntegrationGitFixture.RequireGitAsync(cancellationToken).ConfigureAwait(false);

using TemporaryRepository subDirectory = new();
using TemporaryRepository superDirectory = new();

GitRepository sub = await CreateRepositoryAsync(subDirectory, "s.txt", cancellationToken).ConfigureAwait(false);
GitRepository super = await CreateRepositoryAsync(superDirectory, "m.txt", cancellationToken).ConfigureAwait(false);

await AddSubmoduleAsync(super, sub.LocalPath!, "libs/sub", cancellationToken).ConfigureAwait(false);

GitRepository submodule = new() { LocalPath = Path.Join(superDirectory.RootPath, "libs", "sub").As<AbsoluteDirectoryPath>(), ProcessRunner = super.ProcessRunner };
GitRepository libs = new() { LocalPath = Path.Join(superDirectory.RootPath, "libs").As<AbsoluteDirectoryPath>(), ProcessRunner = super.ProcessRunner };

Assert.IsTrue(await submodule.IsClonedAsync(cancellationToken).ConfigureAwait(false), "the submodule's checkout");
Assert.IsFalse(await libs.IsClonedAsync(cancellationToken).ConfigureAwait(false), "the superproject directory holding it");
}

[TestMethod]
public async Task ReportsTheGitlinkAndTheCheckoutSeparatelyWhenTheyDivergeAsync()
{
Expand Down
54 changes: 49 additions & 5 deletions GitIntegration/Execution/GitProbes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,10 @@ internal static class GitProbes
/// Decides whether a path is inside a git working tree.
/// </summary>
/// <remarks>
/// Shared by <see cref="GitRepository.IsClonedAsync"/> and <see cref="GitClient.IsRepositoryAsync"/>,
/// which both asked this exact question independently before this extraction.
/// <see cref="GitInitBuilder"/> does not use this helper: after answering "is there a repository
/// at exactly this path" via <c>--git-dir</c> instead, it asks a genuinely different question, not
/// this one.
/// Used by <see cref="GitClient.IsRepositoryAsync"/>, whose contract really is "inside a working
/// tree". <see cref="GitRepository.IsClonedAsync"/> asks the narrower
/// <see cref="IsWorkTreeRootAsync"/> instead, and <see cref="GitInitBuilder"/> answers "is there a
/// repository at exactly this path" via <c>--git-dir</c>; neither question is this one.
/// </remarks>
/// <param name="runner">Runs the probe command.</param>
/// <param name="path">The path to probe.</param>
Expand All @@ -39,4 +38,49 @@ internal static async Task<bool> IsWorkTreeAsync(

return result.Success && string.Equals(result.Value, "true", StringComparison.Ordinal);
}

/// <summary>
/// Decides whether a path is the root of a git working tree.
/// </summary>
/// <remarks>
/// <c>--is-inside-work-tree</c> alone walks up the directory tree, so it answers true for any
/// directory beneath another repository's working tree, an empty one included. <c>--show-cdup</c>
/// gives the working-tree root relative to the path: empty at the root and <c>../</c> and so on
/// below it. It reads the working tree rather than the git directory, so a root whose <c>.git</c>
/// is a file pointing elsewhere (a submodule, a linked worktree, a <c>--separate-git-dir</c>
/// checkout) still counts, and a bare repository, which has no working tree, does not.
/// </remarks>
/// <param name="runner">Runs the probe command.</param>
/// <param name="path">The path to probe.</param>
/// <param name="cancellationToken">Cancels the invocation.</param>
/// <returns><see langword="true"/> when <paramref name="path"/> is the root of a working tree.</returns>
internal static async Task<bool> IsWorkTreeRootAsync(
IGitProcessRunner runner,
AbsoluteDirectoryPath path,
CancellationToken cancellationToken)
{
// TryExecuteAsync for the same reason as IsWorkTreeAsync: no repository, or no directory,
// exits 128 and means "no".
GitResult<string> result = await new GitTextBuilder(runner, path, "rev-parse", "--is-inside-work-tree", "--show-cdup")
.TryExecuteAsync(cancellationToken).ConfigureAwait(false);

return result.Success && result.Value is not null && IsWorkTreeRoot(result.Value);
}

/// <summary>
/// Reads the answer to <see cref="IsWorkTreeRootAsync"/>'s probe.
/// </summary>
/// <remarks>
/// The output is trimmed, so at the root the empty <c>--show-cdup</c> line is gone and only
/// <c>true</c> is left; below it a second line holds the way up.
/// </remarks>
/// <param name="output">The trimmed output of the probe.</param>
/// <returns><see langword="true"/> when the probed path is a working-tree root.</returns>
internal static bool IsWorkTreeRoot(string output)
{
string[] lines = Ensure.NotNull(output).Split('\n');

return string.Equals(lines[0].TrimEnd('\r'), "true", StringComparison.Ordinal)
&& (lines.Length == 1 || string.IsNullOrWhiteSpace(lines[1]));
}
}
9 changes: 7 additions & 2 deletions GitIntegration/GitRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,13 @@ public class GitRepository
/// <summary>
/// Decides whether <see cref="LocalPath"/> currently holds a git working tree.
/// </summary>
/// <remarks>
/// The path has to be the working tree's root. A directory beneath some other repository's
/// working tree is not a clone, even though git would run there against the outer repository,
/// so "clone if not cloned" still clones into it.
/// </remarks>
/// <param name="cancellationToken">Cancels the invocation.</param>
/// <returns><see langword="true"/> when the path is inside a working tree.</returns>
/// <returns><see langword="true"/> when the path is the root of a working tree.</returns>
/// <exception cref="InvalidOperationException">This repository has no <see cref="ProcessRunner"/>.</exception>
public Task<bool> IsClonedAsync(CancellationToken cancellationToken = default)
{
Expand All @@ -108,7 +113,7 @@ public Task<bool> IsClonedAsync(CancellationToken cancellationToken = default)
}

private static Task<bool> IsClonedCoreAsync(IGitProcessRunner runner, AbsoluteDirectoryPath localPath, CancellationToken cancellationToken) =>
GitProbes.IsWorkTreeAsync(runner, localPath, cancellationToken);
GitProbes.IsWorkTreeRootAsync(runner, localPath, cancellationToken);

/// <summary>Reports the working tree and index state.</summary>
/// <returns>A fresh builder.</returns>
Expand Down
Loading