From 1f19e8f1806878305dd7b84139e4b4e3d207f16e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 10:34:50 +0000 Subject: [PATCH] Report only a working-tree root as cloned, not a directory inside another repository [patch] IsClonedAsync asked `rev-parse --is-inside-work-tree`, which walks up the directory tree, so an empty directory beneath another repository's working tree reported as cloned. "Clone if not cloned" then skipped the clone and later verbs ran against the outer repository. It now asks `--is-inside-work-tree --show-cdup` and requires an empty way up, so only the root counts. Submodules, linked worktrees and --separate-git-dir checkouts are still roots; IGitClient.IsRepositoryAsync keeps its "inside a working tree" contract. Fixes #185 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019BwRbgKQMY5pZraxHBjxqM --- GitIntegration.Test/GitRepositoryVerbTests.cs | 13 ++++ .../Integration/GitRoundTripTests.cs | 65 +++++++++++++++++++ .../Integration/GitSubmoduleTests.cs | 22 +++++++ GitIntegration/Execution/GitProbes.cs | 54 +++++++++++++-- GitIntegration/GitRepository.cs | 9 ++- 5 files changed, 156 insertions(+), 7 deletions(-) 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.