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
38 changes: 38 additions & 0 deletions GitIntegration.Test/Integration/GitRemoteSyncTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@
{
// The bare call is the natural "what have I not pushed?" query. --remotes counts as revision
// input, so unless HEAD is named git lists nothing, which reads as "nothing unpushed".
CancellationToken cancellationToken = TestContext.CancellationTokenSource.Token;

Check warning on line 177 in GitIntegration.Test/Integration/GitRemoteSyncTests.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=AaE4tvAT5t_1Le8aYuhC&open=AaE4tvAT5t_1Le8aYuhC&pullRequest=218
await IntegrationGitFixture.RequireGitAsync(cancellationToken).ConfigureAwait(false);

using TemporaryRepository remoteDirectory = new();
Expand All @@ -198,14 +198,14 @@
.ExcludingRemoteTrackingRefs()
.ExecuteAsync(cancellationToken).ConfigureAwait(false);

Assert.AreEqual(1, commits.Count);

Check warning on line 201 in GitIntegration.Test/Integration/GitRemoteSyncTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.HasCount' instead of 'Assert.AreEqual'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_GitIntegration&issues=AaE4tvAT5t_1Le8aYuhD&open=AaE4tvAT5t_1Le8aYuhD&pullRequest=218
Assert.AreEqual(unpushed.Sha, commits[0].Sha);
}

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

Check warning on line 208 in GitIntegration.Test/Integration/GitRemoteSyncTests.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=AaE4tvAT5t_1Le8aYuhE&open=AaE4tvAT5t_1Le8aYuhE&pullRequest=218
await IntegrationGitFixture.RequireGitAsync(cancellationToken).ConfigureAwait(false);

using TemporaryRepository workingDirectory = new();
Expand All @@ -226,7 +226,7 @@
.ExcludingRemoteTrackingRefs()
.ExecuteAsync(cancellationToken).ConfigureAwait(false);

Assert.AreEqual(2, commits.Count);

Check warning on line 229 in GitIntegration.Test/Integration/GitRemoteSyncTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.HasCount' instead of 'Assert.AreEqual'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_GitIntegration&issues=AaE4tvAT5t_1Le8aYuhF&open=AaE4tvAT5t_1Le8aYuhF&pullRequest=218
Assert.AreEqual(second.Sha, commits[0].Sha);
Assert.AreEqual(first.Sha, commits[1].Sha);
}
Expand Down Expand Up @@ -608,5 +608,43 @@
Assert.IsTrue(status.Entries.Any(entry => entry.IndexState == GitFileState.Unmerged));
}

[TestMethod]
public async Task StatusReportsAnUpstreamDeletedOnTheRemoteAsGoneAsync()
{
// Once the remote branch is deleted, git can no longer count ahead/behind and reports neither,
// so 0/0 alone would make a branch with an unpushed commit look safe to throw away.
CancellationToken cancellationToken = TestContext.CancellationTokenSource.Token;

Check warning on line 616 in GitIntegration.Test/Integration/GitRemoteSyncTests.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=AaE4tvAT5t_1Le8aYuhG&open=AaE4tvAT5t_1Le8aYuhG&pullRequest=218
await IntegrationGitFixture.RequireGitAsync(cancellationToken).ConfigureAwait(false);

using TemporaryRepository remoteDirectory = new();
using TemporaryRepository workingDirectory = new();

AbsoluteDirectoryPath remote = await CreateBareRemoteAsync(remoteDirectory, cancellationToken).ConfigureAwait(false);
GitRepository repository = await CreateWorkingCopyAsync(workingDirectory, remote, cancellationToken).ConfigureAwait(false);
IGitProcessRunner runner = repository.ProcessRunner!;
GitBranchName feature = "feat".As<GitBranchName>();

_ = await CommitFileAsync(repository, workingDirectory, "a.txt", "one\n", "first", cancellationToken).ConfigureAwait(false);
_ = await new GitTextBuilder(runner, repository.LocalPath, "checkout", "-b", "feat")
.ExecuteAsync(cancellationToken).ConfigureAwait(false);
_ = await repository.Push()
.ToRemote(Origin)
.WithBranch(feature)
.SettingUpstream()
.ExecuteAsync(cancellationToken).ConfigureAwait(false);

GitStatus tracking = await repository.Status().ExecuteAsync(cancellationToken).ConfigureAwait(false);
Assert.IsFalse(tracking.IsUpstreamGone);

_ = await CommitFileAsync(repository, workingDirectory, "b.txt", "two\n", "unpushed", cancellationToken).ConfigureAwait(false);
_ = await new GitTextBuilder(runner, repository.LocalPath, "push", "origin", "--delete", "feat")
.ExecuteAsync(cancellationToken).ConfigureAwait(false);

GitStatus status = await repository.Status().ExecuteAsync(cancellationToken).ConfigureAwait(false);

Assert.AreEqual("origin/feat".As<GitBranchName>(), status.Upstream);
Assert.IsTrue(status.IsUpstreamGone);
}

public TestContext TestContext { get; set; } = null!;
}
28 changes: 28 additions & 0 deletions GitIntegration.Test/Parsing/GitStatusParserTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ public class GitStatusParserTests
"# branch.upstream origin/main" + Nul +
"# branch.ab +3 -7" + Nul;

// git prints branch.upstream from the branch's configuration, but leaves out branch.ab when the
// ref it names is gone: deleted on the remote after a merge, or never fetched.
private const string TrackingAGoneUpstream =
"# branch.oid 9429d2063d91f1097de51a196cb8203b06335738" + Nul +
"# branch.head feat" + Nul +
"# branch.upstream origin/feat" + Nul;

private const string DetachedHead =
"# branch.oid 94947d6da5c05bf1c86af335b33cff8cee83cb3f" + Nul +
"# branch.head (detached)" + Nul;
Expand Down Expand Up @@ -86,6 +93,27 @@ public void ReadsTheUpstreamAndAheadCount()
Assert.AreEqual(0, status.Behind);
}

[TestMethod]
public void ReportsAGoneUpstreamWhenTheAheadBehindHeaderIsMissing()
{
GitStatus status = GitStatusParser.Parse(TrackingAGoneUpstream);

Assert.AreEqual("origin/feat".As<GitBranchName>(), status.Upstream);
Assert.IsTrue(status.IsUpstreamGone);
}

[TestMethod]
public void ReportsAPresentUpstreamAsNotGone()
{
Assert.IsFalse(GitStatusParser.Parse(TrackingAheadOne).IsUpstreamGone);
}

[TestMethod]
public void ReportsNoUpstreamAsNotGone()
{
Assert.IsFalse(GitStatusParser.Parse(CleanOnMain).IsUpstreamGone);
}

[TestMethod]
public void ReadsBothSidesOfADivergedBranch()
{
Expand Down
20 changes: 20 additions & 0 deletions GitIntegration/Models/GitStatus.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,31 @@ public sealed record GitStatus
public GitBranchName? Upstream { get; init; }

/// <summary>Gets how many commits the current branch has that its upstream does not.</summary>
/// <remarks>
/// Zero while <see cref="IsUpstreamGone"/> is <see langword="true"/>, which does not mean the
/// branch is in sync: git cannot compare against an upstream that does not exist.
/// </remarks>
public int Ahead { get; init; }

/// <summary>Gets how many commits the upstream has that the current branch does not.</summary>
/// <remarks>
/// Zero while <see cref="IsUpstreamGone"/> is <see langword="true"/>, for the same reason as
/// <see cref="Ahead"/>.
/// </remarks>
public int Behind { get; init; }

/// <summary>
/// Gets a value indicating whether the current branch is configured to track an
/// <see cref="Upstream"/> that does not exist, because the remote branch was deleted or never
/// fetched.
/// </summary>
/// <remarks>
/// This is the state <c>git status -sb</c> shows as <c>[gone]</c>. While it is
/// <see langword="true"/>, <see cref="Ahead"/> and <see cref="Behind"/> are both zero and say
/// nothing about whether the branch has unpushed commits.
/// </remarks>
public bool IsUpstreamGone { get; init; }

/// <summary>Gets a value indicating whether HEAD points at a commit rather than a branch.</summary>
public bool IsDetached { get; init; }

Expand Down
9 changes: 8 additions & 1 deletion GitIntegration/Parsing/GitStatusParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
GitBranchName? upstream = null;
int ahead = 0;
int behind = 0;
bool hasAheadBehind = false;
bool isDetached = false;
List<GitStatusEntry> entries = [];

Expand All @@ -49,7 +50,7 @@
switch (record[0])
{
case '#':
ReadHeader(record, ref branch, ref upstream, ref ahead, ref behind, ref isDetached);
ReadHeader(record, ref branch, ref upstream, ref ahead, ref behind, ref hasAheadBehind, ref isDetached);
break;

case '1':
Expand All @@ -65,7 +66,7 @@
$"A rename status record has no following original path: '{record}'.");
}

entries.Add(ReadRenameEntry(record, records[++index]));

Check warning on line 69 in GitIntegration/Parsing/GitStatusParser.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Do not update the stop condition variable 'index' in the body of the for loop.

Check warning on line 69 in GitIntegration/Parsing/GitStatusParser.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Do not update the stop condition variable 'index' in the body of the for loop.

Check warning on line 69 in GitIntegration/Parsing/GitStatusParser.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Do not update the stop condition variable 'index' in the body of the for loop.

Check warning on line 69 in GitIntegration/Parsing/GitStatusParser.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Do not update the stop condition variable 'index' in the body of the for loop.
break;

case 'u':
Expand All @@ -91,6 +92,10 @@
Upstream = upstream,
Ahead = ahead,
Behind = behind,

// git writes branch.upstream from the branch's configuration but leaves out branch.ab when
// the ref it names does not exist, so the pair alone is what tells a gone upstream apart.
IsUpstreamGone = upstream is not null && !hasAheadBehind,
IsDetached = isDetached,
Entries = entries,
};
Expand All @@ -102,6 +107,7 @@
ref GitBranchName? upstream,
ref int ahead,
ref int behind,
ref bool hasAheadBehind,
ref bool isDetached)
{
// "# branch.head main" splits into "#", "branch.head", "main"; the value keeps any spaces
Expand Down Expand Up @@ -133,6 +139,7 @@

case "branch.ab":
ReadAheadBehind(parts[2], ref ahead, ref behind);
hasAheadBehind = true;
break;

default:
Expand Down
Loading