diff --git a/GitIntegration.Test/Integration/GitRemoteSyncTests.cs b/GitIntegration.Test/Integration/GitRemoteSyncTests.cs index 09a852a..4b48851 100644 --- a/GitIntegration.Test/Integration/GitRemoteSyncTests.cs +++ b/GitIntegration.Test/Integration/GitRemoteSyncTests.cs @@ -608,5 +608,43 @@ await Assert.ThrowsExactlyAsync( 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; + 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(); + + _ = 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(), status.Upstream); + Assert.IsTrue(status.IsUpstreamGone); + } + public TestContext TestContext { get; set; } = null!; } diff --git a/GitIntegration.Test/Parsing/GitStatusParserTests.cs b/GitIntegration.Test/Parsing/GitStatusParserTests.cs index 2ba8eef..fc49839 100644 --- a/GitIntegration.Test/Parsing/GitStatusParserTests.cs +++ b/GitIntegration.Test/Parsing/GitStatusParserTests.cs @@ -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; @@ -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(), 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() { diff --git a/GitIntegration/Models/GitStatus.cs b/GitIntegration/Models/GitStatus.cs index 8a77ffa..a5e6290 100644 --- a/GitIntegration/Models/GitStatus.cs +++ b/GitIntegration/Models/GitStatus.cs @@ -22,11 +22,31 @@ public sealed record GitStatus public GitBranchName? Upstream { get; init; } /// Gets how many commits the current branch has that its upstream does not. + /// + /// Zero while is , which does not mean the + /// branch is in sync: git cannot compare against an upstream that does not exist. + /// public int Ahead { get; init; } /// Gets how many commits the upstream has that the current branch does not. + /// + /// Zero while is , for the same reason as + /// . + /// public int Behind { get; init; } + /// + /// Gets a value indicating whether the current branch is configured to track an + /// that does not exist, because the remote branch was deleted or never + /// fetched. + /// + /// + /// This is the state git status -sb shows as [gone]. While it is + /// , and are both zero and say + /// nothing about whether the branch has unpushed commits. + /// + public bool IsUpstreamGone { get; init; } + /// Gets a value indicating whether HEAD points at a commit rather than a branch. public bool IsDetached { get; init; } diff --git a/GitIntegration/Parsing/GitStatusParser.cs b/GitIntegration/Parsing/GitStatusParser.cs index 6c931f7..35dc76c 100644 --- a/GitIntegration/Parsing/GitStatusParser.cs +++ b/GitIntegration/Parsing/GitStatusParser.cs @@ -30,6 +30,7 @@ internal static GitStatus Parse(string output) GitBranchName? upstream = null; int ahead = 0; int behind = 0; + bool hasAheadBehind = false; bool isDetached = false; List entries = []; @@ -49,7 +50,7 @@ internal static GitStatus Parse(string output) 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': @@ -91,6 +92,10 @@ internal static GitStatus Parse(string output) 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, }; @@ -102,6 +107,7 @@ private static void ReadHeader( 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 @@ -133,6 +139,7 @@ private static void ReadHeader( case "branch.ab": ReadAheadBehind(parts[2], ref ahead, ref behind); + hasAheadBehind = true; break; default: