diff --git a/_atom/RepoUtils/IApiSurfaceHelper.cs b/_atom/RepoUtils/IApiSurfaceHelper.cs index 265ed2a6..997a67db 100644 --- a/_atom/RepoUtils/IApiSurfaceHelper.cs +++ b/_atom/RepoUtils/IApiSurfaceHelper.cs @@ -2,15 +2,16 @@ namespace Atom.RepoUtils; internal sealed record BreakingChanges(IReadOnlyList MajorChanges, IReadOnlyList MinorChanges); -internal sealed record Change(RootedPath Path, List AddedLines, List DeletedLines); +internal sealed record Change(RootedPath Path, List AddedLines, List DeletedLines); internal interface IApiSurfaceHelper : IBuildAccessor { - BreakingChanges IdentifyBreakingChanges( + async Task IdentifyBreakingChanges( SemVer oldVersion, string oldCommitHash, SemVer newVersion, string newCommitHash, + CancellationToken cancellationToken, params RootedPath[] filesToCheck) { var filesToCheckDisplay = string.Join(", ", filesToCheck); @@ -26,34 +27,41 @@ BreakingChanges IdentifyBreakingChanges( }); var targetFiles = FormatTargetFiles(filesToCheck); - - using var repo = new Repository(RootedFileSystem.AtomRootDirectory); - var oldCommit = repo.Lookup(oldCommitHash); - - if (oldCommit?.IsMissing is not false) - throw new InvalidOperationException($"Commit {oldCommitHash} is missing."); - - var newCommit = repo.Lookup(newCommitHash); - - if (newCommit?.IsMissing is not false) - throw new InvalidOperationException($"Commit {newCommitHash} is missing."); - - var changes = repo.Diff.Compare(oldCommit.Tree, newCommit.Tree); + var arguments = new List + { + "-c", + "core.quotePath=false", + "diff", + "--unified=0", + "--no-color", + "--no-ext-diff", + oldCommitHash, + newCommitHash, + "--", + }; + arguments.AddRange(targetFiles); + + var result = await ProcessRunner.RunAsync(new("git", [.. arguments]) + { + WorkingDirectory = RootedFileSystem.AtomRootDirectory, + OutputLogLevel = LogLevel.Debug, + }, + cancellationToken); + var changes = ParseChanges(result.Output, targetFiles); Logger.LogDebug("Changes: {@Changes}", new { - changes.Content, - changes.LinesDeleted, - changes.LinesAdded, + Content = result.Output, + LinesDeleted = changes.Sum(x => x.DeletedLines.Count), + LinesAdded = changes.Sum(x => x.AddedLines.Count), }); - if (changes is null or { LinesAdded: 0, LinesDeleted: 0 }) + if (changes.Count is 0) return new([], []); IReadOnlyList suspiciousChanges = changes - .Where(x => targetFiles.Contains(x.Path) && x.LinesDeleted > 0) - .Select(x => new Change(RootedFileSystem.AtomRootDirectory / x.Path, x.AddedLines, x.DeletedLines)) + .Where(x => x.DeletedLines.Count > 0) .ToList(); Logger.LogDebug("Suspicious changes: {@SuspiciousChanges}", suspiciousChanges); @@ -62,7 +70,7 @@ BreakingChanges IdentifyBreakingChanges( .Where(x => x.DeletedLines.Count > 0 && x .DeletedLines - .Select(l => l.Content.Trim()) + .Select(line => line.Trim()) .All(deletedLine => !deletedLine.StartsWith(',') && !deletedLine.EndsWith(','))) .ToList(); @@ -78,6 +86,60 @@ BreakingChanges IdentifyBreakingChanges( return new(majorChanges, minorChanges); } + private List ParseChanges(string patch, HashSet targetFiles) + { + var changes = new List(); + Change? currentChange = null; + string? oldPath = null; + + using var reader = new StringReader(patch); + + while (reader.ReadLine() is { } line) + { + if (line.StartsWith("diff --git ", StringComparison.Ordinal)) + { + currentChange = null; + oldPath = null; + } + else if (line.StartsWith("--- ", StringComparison.Ordinal)) + { + oldPath = ParseDiffPath(line[4..]); + } + else if (line.StartsWith("+++ ", StringComparison.Ordinal)) + { + var newPath = ParseDiffPath(line[4..]); + var path = newPath ?? oldPath; + + if (path is not null && targetFiles.Contains(path)) + { + currentChange = new(RootedFileSystem.AtomRootDirectory / path, [], []); + changes.Add(currentChange); + } + } + else if (currentChange is not null && line.StartsWith('+')) + { + currentChange.AddedLines.Add(line[1..]); + } + else if (currentChange is not null && line.StartsWith('-')) + { + currentChange.DeletedLines.Add(line[1..]); + } + } + + return changes; + } + + private static string? ParseDiffPath(string value) + { + if (value is "/dev/null") + return null; + + return value.StartsWith("a/", StringComparison.Ordinal) || + value.StartsWith("b/", StringComparison.Ordinal) + ? value[2..] + : value; + } + private HashSet FormatTargetFiles(RootedPath[] filesToCheck) { var targetFiles = filesToCheck diff --git a/_atom/RepoUtils/ICheckPrForBreakingChanges.cs b/_atom/RepoUtils/ICheckPrForBreakingChanges.cs index 47793205..5a2eed0e 100644 --- a/_atom/RepoUtils/ICheckPrForBreakingChanges.cs +++ b/_atom/RepoUtils/ICheckPrForBreakingChanges.cs @@ -20,15 +20,19 @@ internal interface ICheckPrForBreakingChanges : IGithubHelper, IPullRequestHelpe var owner = Github.Variables.RepositoryOwner; Logger.LogDebug("Target repository owner: {Owner}", owner); - using var repo = new Repository(RootedFileSystem.AtomRootDirectory); - - var currentCommitHash = repo.Head.Tip.Sha; + var currentCommitResult = await ProcessRunner.RunAsync(new("git", "rev-parse HEAD") + { + WorkingDirectory = RootedFileSystem.AtomRootDirectory, + OutputLogLevel = LogLevel.Debug, + }, + cancellationToken); + var currentCommitHash = currentCommitResult.Output.Trim(); Logger.LogDebug("Current commit hash: {CommitHash}", currentCommitHash); var currentVersion = BuildVersion; Logger.LogDebug("Current version: {Version}", currentVersion); - var latestReleaseInfo = FindLatestReleaseInfo(repo, currentVersion); + var latestReleaseInfo = await FindLatestReleaseInfo(currentVersion, cancellationToken); Logger.LogDebug("Latest release info: {ReleaseInfo}", latestReleaseInfo); if (latestReleaseInfo is null) @@ -43,10 +47,11 @@ internal interface ICheckPrForBreakingChanges : IGithubHelper, IPullRequestHelpe currentVersion, latestReleaseInfo.Version); - var breakingChanges = IdentifyBreakingChanges(latestReleaseInfo.Version, + var breakingChanges = await IdentifyBreakingChanges(latestReleaseInfo.Version, latestReleaseInfo.CommitHash, currentVersion, currentCommitHash, + cancellationToken, FilesToCheck); Logger.LogInformation( @@ -136,23 +141,30 @@ await AddCheckStatus(owner, cancellationToken); }); - ReleaseInfo? FindLatestReleaseInfo(Repository repo, SemVer currentVersion) + private async Task FindLatestReleaseInfo( + SemVer currentVersion, + CancellationToken cancellationToken) { - var releaseVersions = repo - .Tags - .Select(x => new + var tagsResult = await ProcessRunner.RunAsync(new("git", ["tag", "--list", "v*"]) { - Tag = x, - Version = !x.FriendlyName.StartsWith('v') - ? null - : !SemVer.TryParse(x.FriendlyName[1..], out var version) + WorkingDirectory = RootedFileSystem.AtomRootDirectory, + OutputLogLevel = LogLevel.Debug, + }, + cancellationToken); + var releaseVersions = tagsResult + .Output + .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(tag => new + { + Tag = tag, + Version = !SemVer.TryParse(tag[1..], out var version) ? null : version, }) .Where(x => x.Version is not null && x.Version < currentVersion) .Select(x => new { - Tag = x.Tag!, + x.Tag, Version = x.Version!, }) .ToList(); @@ -165,8 +177,14 @@ await AddCheckStatus(owner, } var version = releaseVersions.MaxBy(x => x.Version)!; + var commitResult = await ProcessRunner.RunAsync(new("git", ["rev-list", "-n", "1", version.Tag]) + { + WorkingDirectory = RootedFileSystem.AtomRootDirectory, + OutputLogLevel = LogLevel.Debug, + }, + cancellationToken); - return new(version.Tag.Target.Sha, version.Version); + return new(commitResult.Output.Trim(), version.Version); } private async Task AddCheckStatus( @@ -213,8 +231,6 @@ private async Task AddCheckStatus( if (prQueryResult.Id.Value is null) throw new StepFailedException("Could not find pull request."); - using var repo = new Repository(RootedFileSystem.AtomRootDirectory); - var checkRunMutation = new Mutation() .CreateCheckRun(new CreateCheckRunInput { diff --git a/_atom/_atom.csproj b/_atom/_atom.csproj index 7e5bce15..2ef466e6 100644 --- a/_atom/_atom.csproj +++ b/_atom/_atom.csproj @@ -13,7 +13,6 @@ - diff --git a/_atom/_usings.cs b/_atom/_usings.cs index 7102ce14..e1cc3334 100644 --- a/_atom/_usings.cs +++ b/_atom/_usings.cs @@ -29,10 +29,7 @@ global using Invex.StructuredText.Expressions; global using Invex.StructuredText.GithubActions.DependabotConfigModel.Model; global using Invex.StructuredText.GithubActions.GithubActionModel; -global using LibGit2Sharp; global using Microsoft.Extensions.Logging; global using Octokit.GraphQL; global using Octokit.GraphQL.Internal; global using Octokit.GraphQL.Model; -global using Commit = LibGit2Sharp.Commit; -global using Repository = LibGit2Sharp.Repository;