diff --git a/GitIntegration.Test/GitRepositoryVerbTests.cs b/GitIntegration.Test/GitRepositoryVerbTests.cs index a019395..73269c4 100644 --- a/GitIntegration.Test/GitRepositoryVerbTests.cs +++ b/GitIntegration.Test/GitRepositoryVerbTests.cs @@ -135,6 +135,19 @@ public async Task IsClonedReportsTrueWhenGitFindsAWorkingTreeAsync() 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); + + Assert.IsFalse(isCloned); + } + [TestMethod] public async Task IsClonedReportsFalseForAPathThatIsNotAWorkingTreeAsync() { diff --git a/GitIntegration.Test/Integration/GitRoundTripTests.cs b/GitIntegration.Test/Integration/GitRoundTripTests.cs index ce55b5e..0189eea 100644 --- a/GitIntegration.Test/Integration/GitRoundTripTests.cs +++ b/GitIntegration.Test/Integration/GitRoundTripTests.cs @@ -152,6 +152,71 @@ public async Task InitReportsASubdirectoryOfARepositoryAsFreshAsync() 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; + 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(), ProcessRunner = outer.ProcessRunner }; + GitRepository subdirectory = new() { LocalPath = Path.Join(temporary.RootPath, "sub").As(), 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; + 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(), 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()).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(), ProcessRunner = repository.ProcessRunner }; + + Assert.IsTrue(await linked.IsClonedAsync(cancellationToken).ConfigureAwait(false)); + } + [TestMethod] public async Task AddAndCommitProduceAReadableCommitAsync() { diff --git a/GitIntegration.Test/Integration/GitSubmoduleTests.cs b/GitIntegration.Test/Integration/GitSubmoduleTests.cs index 566e3d4..135bf9d 100644 --- a/GitIntegration.Test/Integration/GitSubmoduleTests.cs +++ b/GitIntegration.Test/Integration/GitSubmoduleTests.cs @@ -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; @@ -115,6 +116,27 @@ public async Task ReportsASynchronisedSubmoduleAsync() Assert.IsNotNull(submodules[0].Describe); } + [TestMethod] + public async Task IsClonedReportsASubmoduleCheckoutButNotTheDirectoryHoldingItAsync() + { + CancellationToken cancellationToken = TestContext.CancellationTokenSource.Token; + 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(), ProcessRunner = super.ProcessRunner }; + GitRepository libs = new() { LocalPath = Path.Join(superDirectory.RootPath, "libs").As(), 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() { diff --git a/GitIntegration/Execution/GitProbes.cs b/GitIntegration/Execution/GitProbes.cs index ac0889b..b458b49 100644 --- a/GitIntegration/Execution/GitProbes.cs +++ b/GitIntegration/Execution/GitProbes.cs @@ -17,11 +17,10 @@ internal static class GitProbes /// Decides whether a path is inside a git working tree. /// /// - /// Shared by and , - /// which both asked this exact question independently before this extraction. - /// does not use this helper: after answering "is there a repository - /// at exactly this path" via --git-dir instead, it asks a genuinely different question, not - /// this one. + /// Used by , whose contract really is "inside a working + /// tree". asks the narrower + /// instead, and answers "is there a + /// repository at exactly this path" via --git-dir; neither question is this one. /// /// Runs the probe command. /// The path to probe. @@ -39,4 +38,49 @@ internal static async Task IsWorkTreeAsync( return result.Success && string.Equals(result.Value, "true", StringComparison.Ordinal); } + + /// + /// Decides whether a path is the root of a git working tree. + /// + /// + /// --is-inside-work-tree alone walks up the directory tree, so it answers true for any + /// directory beneath another repository's working tree, an empty one included. --show-cdup + /// gives the working-tree root relative to the path: empty at the root and ../ and so on + /// below it. It reads the working tree rather than the git directory, so a root whose .git + /// is a file pointing elsewhere (a submodule, a linked worktree, a --separate-git-dir + /// checkout) still counts, and a bare repository, which has no working tree, does not. + /// + /// Runs the probe command. + /// The path to probe. + /// Cancels the invocation. + /// when is the root of a working tree. + internal static async Task 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 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); + } + + /// + /// Reads the answer to 's probe. + /// + /// + /// The output is trimmed, so at the root the empty --show-cdup line is gone and only + /// true is left; below it a second line holds the way up. + /// + /// The trimmed output of the probe. + /// when the probed path is a working-tree root. + 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])); + } } diff --git a/GitIntegration/GitRepository.cs b/GitIntegration/GitRepository.cs index 53b60ee..437463b 100644 --- a/GitIntegration/GitRepository.cs +++ b/GitIntegration/GitRepository.cs @@ -93,8 +93,13 @@ public class GitRepository /// /// Decides whether currently holds a git working tree. /// + /// + /// 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. + /// /// Cancels the invocation. - /// when the path is inside a working tree. + /// when the path is the root of a working tree. /// This repository has no . public Task IsClonedAsync(CancellationToken cancellationToken = default) { @@ -108,7 +113,7 @@ public Task IsClonedAsync(CancellationToken cancellationToken = default) } private static Task IsClonedCoreAsync(IGitProcessRunner runner, AbsoluteDirectoryPath localPath, CancellationToken cancellationToken) => - GitProbes.IsWorkTreeAsync(runner, localPath, cancellationToken); + GitProbes.IsWorkTreeRootAsync(runner, localPath, cancellationToken); /// Reports the working tree and index state. /// A fresh builder.