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
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,10 @@ Non-obvious, load-bearing design points:
4. **`Init` probes before running, so `GitInitResult.AlreadyExisted` can tell a caller whether a
repository was already there.** `git init` is idempotent and announces the difference only in
prose, and it silently ignores `--initial-branch` when re-initialising — the probe (a
`rev-parse --git-dir` check) is the only way to know either fact.
`rev-parse --is-bare-repository --git-dir --show-cdup` check) is the only way to know either
fact. `--git-dir` alone is not enough: it prints an absolute path both for an ancestor
repository and for a submodule, linked worktree or `--separate-git-dir` repository rooted at the
target, and only the empty `--show-cdup` tells the second apart.

5. **`Clone`'s destination check is advisory.** Git enforces the same rule itself; the pre-check
exists only so a doomed clone fails before paying its network cost, and it is deliberately racy —
Expand Down
37 changes: 32 additions & 5 deletions GitIntegration.Test/Builders/GitInitBuilderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,9 @@
{
// git init on an existing repository exits 0 and only says "Reinitialized" in prose, so the
// probe is the sole machine-readable signal. ".git" is what --git-dir prints for a non-bare
// repository at exactly this path.
// repository at exactly this path, and --show-cdup prints an empty line at its root.
ScriptedGitProcessRunner runner = new ScriptedGitProcessRunner()
.Then(standardOutput: ".git\n")
.Then(standardOutput: "false\n.git\n\n")
.Then(standardOutput: "Reinitialized existing Git repository in /dev/new-repo/.git/\n");
GitInitBuilder builder = new(runner, Target);

Expand All @@ -109,7 +109,7 @@
{
// The probe reports, it does not gate: git init is idempotent and running it is harmless.
ScriptedGitProcessRunner runner = new ScriptedGitProcessRunner()
.Then(standardOutput: ".git\n")
.Then(standardOutput: "false\n.git\n\n")
.Then(standardOutput: "Reinitialized existing Git repository\n");
GitInitBuilder builder = new(runner, Target);

Expand All @@ -126,7 +126,7 @@
// above it: a real repository exists, but not at this path, so init here creates a new one
// nested inside it. That must not be reported as AlreadyExisted.
ScriptedGitProcessRunner runner = new ScriptedGitProcessRunner()
.Then(standardOutput: (OperatingSystem.IsWindows() ? @"C:\dev\.git" : "/dev/.git") + "\n")
.Then(standardOutput: "false\n" + (OperatingSystem.IsWindows() ? @"C:\dev\.git" : "/dev/.git") + "\n../\n")
.Then(standardOutput: "Initialized empty Git repository in /dev/new-repo/sub/.git/\n");
GitInitBuilder builder = new(runner, Target);

Expand All @@ -142,7 +142,7 @@
// --is-inside-work-tree got backwards, since a bare repository has no working tree to be
// inside.
ScriptedGitProcessRunner runner = new ScriptedGitProcessRunner()
.Then(standardOutput: ".\n")
.Then(standardOutput: "true\n.\n")
.Then(standardOutput: "Reinitialized existing Git repository in /dev/new-repo/\n");
GitInitBuilder builder = new(runner, Target);

Expand All @@ -151,6 +151,33 @@
Assert.IsTrue(result.AlreadyExisted);
}

[TestMethod]
public async Task ReportsARepositoryWhoseGitDirectoryLivesElsewhereAsAlreadyExistingAsync()
{
// A submodule, a linked worktree or a --separate-git-dir repository has a .git file at the
// target pointing elsewhere, so --git-dir prints an absolute path just as it does for an
// ancestor. The empty --show-cdup line is what says the working tree is rooted here.
ScriptedGitProcessRunner runner = new ScriptedGitProcessRunner()
.Then(standardOutput: "false\n" + (OperatingSystem.IsWindows() ? @"C:\elsewhere\sg" : "/elsewhere/sg") + "\n\n")
.Then(standardOutput: "Reinitialized existing Git repository in /elsewhere/sg/\n");
GitInitBuilder builder = new(runner, Target);

GitInitResult result = await builder.ExecuteAsync(TestContext.CancellationTokenSource.Token).ConfigureAwait(false);

Check warning on line 165 in GitIntegration.Test/Builders/GitInitBuilderTests.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=AaESFhy3K7f85vLbU39n&open=AaESFhy3K7f85vLbU39n&pullRequest=182

Assert.IsTrue(result.AlreadyExisted);
}

[TestMethod]
public void ReadsTheProbeOutputForEveryLayout()
{
Assert.IsTrue(GitInitBuilder.IsRepositoryRoot("false\n.git"), "non-bare repository at the target");
Assert.IsTrue(GitInitBuilder.IsRepositoryRoot("true\n."), "bare repository at the target");
Assert.IsTrue(GitInitBuilder.IsRepositoryRoot("false\n/elsewhere/sg"), ".git file at the target");
Assert.IsFalse(GitInitBuilder.IsRepositoryRoot("false\n/dev/.git\n../"), "subdirectory of a repository");
Assert.IsFalse(GitInitBuilder.IsRepositoryRoot("true\n/dev/bare"), "subdirectory of a bare repository");
Assert.IsFalse(GitInitBuilder.IsRepositoryRoot(string.Empty), "no output");
}

[TestMethod]
public async Task ThrowsWhenInitItselfFailsAsync()
{
Expand Down
68 changes: 68 additions & 0 deletions GitIntegration.Test/Integration/GitRoundTripTests.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 @@ -84,6 +85,73 @@
Assert.IsTrue(second.AlreadyExisted);
}

[TestMethod]
public async Task InitReportsASeparateGitDirRepositoryAsAlreadyExistingAsync()
{
// The target's .git is a file pointing at a git directory elsewhere, so --git-dir prints an
// absolute path, as it does for an ancestor; git re-initializes it all the same.
CancellationToken cancellationToken = TestContext.CancellationTokenSource.Token;

Check warning on line 93 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=AaESFh37K7f85vLbU39o&open=AaESFh37K7f85vLbU39o&pullRequest=182
await IntegrationGitFixture.RequireGitAsync(cancellationToken).ConfigureAwait(false);

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

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

GitInitResult init = await IntegrationGitFixture.CreateClient()
.Init(Path.Join(temporary.RootPath, "s").As<AbsoluteDirectoryPath>())
.ExecuteAsync(cancellationToken).ConfigureAwait(false);

Assert.IsTrue(init.AlreadyExisted);
}

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

Check warning on line 115 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=AaESFh37K7f85vLbU39p&open=AaESFh37K7f85vLbU39p&pullRequest=182
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);

GitInitResult init = await IntegrationGitFixture.CreateClient()
.Init(worktree.As<AbsoluteDirectoryPath>())
.ExecuteAsync(cancellationToken).ConfigureAwait(false);

Assert.IsTrue(init.AlreadyExisted);
}

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

Check warning on line 140 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=AaESFh37K7f85vLbU39q&open=AaESFh37K7f85vLbU39q&pullRequest=182
await IntegrationGitFixture.RequireGitAsync(cancellationToken).ConfigureAwait(false);

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

temporary.WriteFile("sub/a.txt", "one\n");

GitInitResult init = await IntegrationGitFixture.CreateClient()
.Init(Path.Join(temporary.RootPath, "sub").As<AbsoluteDirectoryPath>())
.ExecuteAsync(cancellationToken).ConfigureAwait(false);

Assert.IsFalse(init.AlreadyExisted);
}

[TestMethod]
public async Task AddAndCommitProduceAReadableCommitAsync()
{
Expand Down
55 changes: 48 additions & 7 deletions GitIntegration/Builders/GitInitBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -129,21 +129,62 @@ public override async Task<GitResult<GitInitResult>> TryExecuteAsync(Cancellatio

private async Task<bool> ProbeAsync(CancellationToken cancellationToken)
{
// Asks "is there a repository at exactly this path", via --git-dir, deliberately not via
// Asks "is there a repository at exactly this path", deliberately not via
// GitProbes.IsWorkTreeAsync's --is-inside-work-tree: that answers a different question,
// whether the path is inside *some* working tree. That wrongly reports AlreadyExisted =
// true for a plain subdirectory of an existing repository (git init there creates a
// nested repository), and wrongly reports AlreadyExisted = false for an existing bare
// repository (git init there only prints a re-init warning). --git-dir discriminates all
// four cases: ".git" (a non-bare repository at exactly this path), "." (a bare repository
// at exactly this path), an absolute path (a repository exists, but as an ancestor, not
// here), or a non-zero exit (no repository, or the directory does not exist).
// repository (git init there only prints a re-init warning).
//
// TryExecuteAsync, because failure is the expected answer: the directory may hold no
// repository, or may not exist at all, and both exit 128 and both mean "not yet".
GitResult<string> probe = await new GitTextBuilder(Runner, _targetPath, "rev-parse", "--git-dir")
GitResult<string> probe = await new GitTextBuilder(
Runner, _targetPath, "rev-parse", "--is-bare-repository", "--git-dir", "--show-cdup")
.TryExecuteAsync(cancellationToken).ConfigureAwait(false);

return probe.Success && probe.Value is ".git" or ".";
return probe.Success && probe.Value is not null && IsRepositoryRoot(probe.Value);
}

/// <summary>
/// Reads the probe's answer: whether the repository git found is rooted at the target itself.
/// </summary>
/// <remarks>
/// <para>
/// <c>--git-dir</c> alone cannot tell. It prints <c>.git</c> for an ordinary repository at the
/// target and <c>.</c> for a bare one, but an absolute path both for a repository rooted above
/// the target and for one at the target whose <c>.git</c> is a file pointing elsewhere — a
/// submodule, a linked worktree, or a <c>--separate-git-dir</c> repository. git re-initializes
/// the latter, so it is the working-tree root, not the git directory, that decides.
/// </para>
/// <para>
/// <c>--show-cdup</c> gives that root relative to the target: empty at the root, <c>../</c>
/// and so on below it. It prints nothing at all outside a working tree, and the output is
/// trimmed, so a missing line and an empty one read the same; the bare check comes first
/// because a subdirectory of a bare repository is the one place that difference would matter.
/// </para>
/// </remarks>
/// <param name="output">The trimmed output of the probe.</param>
/// <returns><see langword="true"/> when a repository already exists at the target.</returns>
internal static bool IsRepositoryRoot(string output)
{
string[] lines = Ensure.NotNull(output).Split('\n');

if (lines.Length < 2)
{
return false;
}

// "." is the target itself being a git directory: a bare repository, or a .git directory.
if (string.Equals(lines[1].TrimEnd('\r'), ".", StringComparison.Ordinal))
{
return true;
}

if (string.Equals(lines[0].TrimEnd('\r'), "true", StringComparison.Ordinal))
{
return false;
}

return lines.Length < 3 || lines[2].TrimEnd('\r').Length == 0;
}
}
Loading