From 3876b0ca4c3bef0020d0c319987321cba152d220 Mon Sep 17 00:00:00 2001 From: Marek Magath Date: Wed, 24 Dec 2025 00:08:31 +0100 Subject: [PATCH 1/7] Change GetDiff implementation --- .../Commands/Publish/PublishChangesetCommand.cs | 2 +- .../{ => Commands/Publish}/Services/DotnetService.cs | 2 +- .../{ => Commands/Publish}/Services/GitService.cs | 4 ++-- .../{ => Commands/Publish}/Services/IDotnetService.cs | 2 +- .../{ => Commands/Publish}/Services/IGitService.cs | 2 +- src/SolarWinds.Changesets/Program.cs | 2 +- .../Publish/PublishChangesetCommandTests.cs | 2 +- 7 files changed, 8 insertions(+), 8 deletions(-) rename src/SolarWinds.Changesets/{ => Commands/Publish}/Services/DotnetService.cs (95%) rename src/SolarWinds.Changesets/{ => Commands/Publish}/Services/GitService.cs (85%) rename src/SolarWinds.Changesets/{ => Commands/Publish}/Services/IDotnetService.cs (94%) rename src/SolarWinds.Changesets/{ => Commands/Publish}/Services/IGitService.cs (89%) diff --git a/src/SolarWinds.Changesets/Commands/Publish/PublishChangesetCommand.cs b/src/SolarWinds.Changesets/Commands/Publish/PublishChangesetCommand.cs index b0b2b53..e6f4bc1 100644 --- a/src/SolarWinds.Changesets/Commands/Publish/PublishChangesetCommand.cs +++ b/src/SolarWinds.Changesets/Commands/Publish/PublishChangesetCommand.cs @@ -1,4 +1,4 @@ -using SolarWinds.Changesets.Services; +using SolarWinds.Changesets.Commands.Publish.Services; using SolarWinds.Changesets.Shared; using Spectre.Console; using Spectre.Console.Cli; diff --git a/src/SolarWinds.Changesets/Services/DotnetService.cs b/src/SolarWinds.Changesets/Commands/Publish/Services/DotnetService.cs similarity index 95% rename from src/SolarWinds.Changesets/Services/DotnetService.cs rename to src/SolarWinds.Changesets/Commands/Publish/Services/DotnetService.cs index af63dae..3a318e2 100644 --- a/src/SolarWinds.Changesets/Services/DotnetService.cs +++ b/src/SolarWinds.Changesets/Commands/Publish/Services/DotnetService.cs @@ -1,6 +1,6 @@ using SolarWinds.Changesets.Shared; -namespace SolarWinds.Changesets.Services; +namespace SolarWinds.Changesets.Commands.Publish.Services; /// public sealed class DotnetService : IDotnetService diff --git a/src/SolarWinds.Changesets/Services/GitService.cs b/src/SolarWinds.Changesets/Commands/Publish/Services/GitService.cs similarity index 85% rename from src/SolarWinds.Changesets/Services/GitService.cs rename to src/SolarWinds.Changesets/Commands/Publish/Services/GitService.cs index d0fcbd6..4b22c5b 100644 --- a/src/SolarWinds.Changesets/Services/GitService.cs +++ b/src/SolarWinds.Changesets/Commands/Publish/Services/GitService.cs @@ -1,6 +1,6 @@ using SolarWinds.Changesets.Shared; -namespace SolarWinds.Changesets.Services; +namespace SolarWinds.Changesets.Commands.Publish.Services; /// public sealed class GitService : IGitService @@ -23,7 +23,7 @@ public async Task GetDiff(string sourcePath) { return await _processExecutor.Execute( "git", - $"diff --name-only {sourcePath}", + $"diff --name-only HEAD~1 HEAD -- '*.csproj' {sourcePath}", Constants.WorkingDirectoryFullPath ); } diff --git a/src/SolarWinds.Changesets/Services/IDotnetService.cs b/src/SolarWinds.Changesets/Commands/Publish/Services/IDotnetService.cs similarity index 94% rename from src/SolarWinds.Changesets/Services/IDotnetService.cs rename to src/SolarWinds.Changesets/Commands/Publish/Services/IDotnetService.cs index 2fbc473..4d5e495 100644 --- a/src/SolarWinds.Changesets/Services/IDotnetService.cs +++ b/src/SolarWinds.Changesets/Commands/Publish/Services/IDotnetService.cs @@ -1,6 +1,6 @@ using SolarWinds.Changesets.Shared; -namespace SolarWinds.Changesets.Services; +namespace SolarWinds.Changesets.Commands.Publish.Services; /// /// Provides functionality to interact with the .NET CLI for operations such as packing and publishing NuGet packages. diff --git a/src/SolarWinds.Changesets/Services/IGitService.cs b/src/SolarWinds.Changesets/Commands/Publish/Services/IGitService.cs similarity index 89% rename from src/SolarWinds.Changesets/Services/IGitService.cs rename to src/SolarWinds.Changesets/Commands/Publish/Services/IGitService.cs index 7d13ebd..8a955de 100644 --- a/src/SolarWinds.Changesets/Services/IGitService.cs +++ b/src/SolarWinds.Changesets/Commands/Publish/Services/IGitService.cs @@ -1,6 +1,6 @@ using SolarWinds.Changesets.Shared; -namespace SolarWinds.Changesets.Services; +namespace SolarWinds.Changesets.Commands.Publish.Services; /// /// Provides Git-related services, such as retrieving file differences. diff --git a/src/SolarWinds.Changesets/Program.cs b/src/SolarWinds.Changesets/Program.cs index c0143d6..eda75c1 100644 --- a/src/SolarWinds.Changesets/Program.cs +++ b/src/SolarWinds.Changesets/Program.cs @@ -2,11 +2,11 @@ using SolarWinds.Changesets.Commands.Add; using SolarWinds.Changesets.Commands.Init; using SolarWinds.Changesets.Commands.Publish; +using SolarWinds.Changesets.Commands.Publish.Services; using SolarWinds.Changesets.Commands.Status; using SolarWinds.Changesets.Commands.Version; using SolarWinds.Changesets.Commands.Version.Helpers; using SolarWinds.Changesets.Infrastructure; -using SolarWinds.Changesets.Services; using SolarWinds.Changesets.Shared; using Spectre.Console.Cli; diff --git a/tests/SolarWinds.Changesets.Tests/Publish/PublishChangesetCommandTests.cs b/tests/SolarWinds.Changesets.Tests/Publish/PublishChangesetCommandTests.cs index 43ae625..5828752 100644 --- a/tests/SolarWinds.Changesets.Tests/Publish/PublishChangesetCommandTests.cs +++ b/tests/SolarWinds.Changesets.Tests/Publish/PublishChangesetCommandTests.cs @@ -2,8 +2,8 @@ using Moq; using SolarWinds.Changesets.Commands.Init; using SolarWinds.Changesets.Commands.Publish; +using SolarWinds.Changesets.Commands.Publish.Services; using SolarWinds.Changesets.Infrastructure; -using SolarWinds.Changesets.Services; using SolarWinds.Changesets.Shared; using Spectre.Console.Testing; From 64b7e907f4a426598d198b4a1957fff08368ed02 Mon Sep 17 00:00:00 2001 From: Marek Magath Date: Tue, 30 Dec 2025 18:09:33 +0100 Subject: [PATCH 2/7] Add integration tests for git service --- .../Publish/PublishChangesetCommand.cs | 2 +- .../Commands/Publish/Services/GitService.cs | 6 +- .../Commands/Publish/Services/IGitService.cs | 3 +- .../Publish/GitServiceTests.cs | 184 ++++++++++++++++++ .../Publish/PublishChangesetCommandTests.cs | 6 +- 5 files changed, 193 insertions(+), 8 deletions(-) create mode 100644 tests/SolarWinds.Changesets.Tests/Publish/GitServiceTests.cs diff --git a/src/SolarWinds.Changesets/Commands/Publish/PublishChangesetCommand.cs b/src/SolarWinds.Changesets/Commands/Publish/PublishChangesetCommand.cs index e6f4bc1..9e1eaa6 100644 --- a/src/SolarWinds.Changesets/Commands/Publish/PublishChangesetCommand.cs +++ b/src/SolarWinds.Changesets/Commands/Publish/PublishChangesetCommand.cs @@ -40,7 +40,7 @@ public override async Task ExecuteCommandAsync(CommandContext context) _console.WriteLine("Determining projects to publish ..."); _console.WriteLine(); - ProcessOutput processOutput = await _gitService.GetDiff(ChangesetConfig.SourcePath); + ProcessOutput processOutput = await _gitService.GetDiff(Constants.WorkingDirectoryFullPath, ChangesetConfig.SourcePath); List changedCsharpProjectNames = processOutput.Output.Where(x => x.Contains(".csproj", StringComparison.Ordinal)).ToList(); if (changedCsharpProjectNames.Count == 0) diff --git a/src/SolarWinds.Changesets/Commands/Publish/Services/GitService.cs b/src/SolarWinds.Changesets/Commands/Publish/Services/GitService.cs index 4b22c5b..afb837e 100644 --- a/src/SolarWinds.Changesets/Commands/Publish/Services/GitService.cs +++ b/src/SolarWinds.Changesets/Commands/Publish/Services/GitService.cs @@ -19,12 +19,12 @@ public GitService(IProcessExecutor processExecutor) } /// - public async Task GetDiff(string sourcePath) + public async Task GetDiff(string workingDirectory, string sourcePath) { return await _processExecutor.Execute( "git", - $"diff --name-only HEAD~1 HEAD -- '*.csproj' {sourcePath}", - Constants.WorkingDirectoryFullPath + $"diff --name-only HEAD~1 HEAD {sourcePath}", + workingDirectory ); } } diff --git a/src/SolarWinds.Changesets/Commands/Publish/Services/IGitService.cs b/src/SolarWinds.Changesets/Commands/Publish/Services/IGitService.cs index 8a955de..1b3e79a 100644 --- a/src/SolarWinds.Changesets/Commands/Publish/Services/IGitService.cs +++ b/src/SolarWinds.Changesets/Commands/Publish/Services/IGitService.cs @@ -10,7 +10,8 @@ public interface IGitService /// /// Retrieves a list of file names that have changed in the specified source path. /// + /// Working directory. /// The path to check for file differences. /// Process output and exit code. - Task GetDiff(string sourcePath); + Task GetDiff(string workingDirectory, string sourcePath); } diff --git a/tests/SolarWinds.Changesets.Tests/Publish/GitServiceTests.cs b/tests/SolarWinds.Changesets.Tests/Publish/GitServiceTests.cs new file mode 100644 index 0000000..267ecfc --- /dev/null +++ b/tests/SolarWinds.Changesets.Tests/Publish/GitServiceTests.cs @@ -0,0 +1,184 @@ +using AwesomeAssertions; +using SolarWinds.Changesets.Commands.Publish.Services; +using SolarWinds.Changesets.Shared; + +namespace SolarWinds.Changesets.Tests.Publish; + +[TestFixture] +internal sealed class GitServiceTests +{ + private string _tempRepositoryAbsolutePath = string.Empty; + private ProcessExecutor _processExecutor = null!; + private GitService _gitService = null!; + + [SetUp] + public async Task SetUp() + { + string testDirectory = TestContext.CurrentContext.TestDirectory; + _tempRepositoryAbsolutePath = Path.Join(testDirectory, $"git-service-test-{Guid.NewGuid()}"); + Directory.CreateDirectory(_tempRepositoryAbsolutePath); + + _processExecutor = new ProcessExecutor(); + await ExecuteGitCommand("init"); + await ExecuteGitCommand("config --local user.email \"test@example.com\""); + await ExecuteGitCommand("config --local user.name \"Test User\""); + await ExecuteGitCommand("config --local commit.gpgSign false"); + + _gitService = new GitService(_processExecutor); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_tempRepositoryAbsolutePath)) + { + DeleteDirectoryWithRetry(_tempRepositoryAbsolutePath); + } + } + + [Test] + public async Task GetDiff_WithModifiedCsprojFile_ReturnsModifiedProject() + { + // Arrange + string projectName = "TestProject"; + string csprojAbsolutePath = Path.Join(_tempRepositoryAbsolutePath, $"{projectName}.csproj"); + + // Create initial .csproj file + await File.WriteAllTextAsync(csprojAbsolutePath, """ + + + net8.0 + 1.0.0 + + + """ + ); + + await ExecuteGitCommand("add ."); + await ExecuteGitCommand("commit -m \"Initial commit\""); + + // Modify .csproj file + await File.WriteAllTextAsync(csprojAbsolutePath, """ + + + net8.0 + 1.1.0 + + + """ + ); + + await ExecuteGitCommand("add ."); + await ExecuteGitCommand("commit -m \"Update version\""); + + // Act + ProcessOutput result = await _gitService.GetDiff(_tempRepositoryAbsolutePath, string.Empty); + + // Assert + result.ExitCode.Should().Be(0); + result.Output.Should().HaveCount(1); + result.Output.First().Should().EndWith($"{projectName}.csproj"); + } + + [Test] + public async Task GetDiff_WithNonCsprojChanges_ReturnsEmpty() + { + // Arrange + string readmePath = Path.Join(_tempRepositoryAbsolutePath, "README.md"); + + await File.WriteAllTextAsync(readmePath, "# Test"); + await ExecuteGitCommand("add ."); + await ExecuteGitCommand("commit -m \"Initial commit\""); + + await File.WriteAllTextAsync(readmePath, "# Test Updated"); + await ExecuteGitCommand("add ."); + await ExecuteGitCommand("commit -m \"Update README\""); + + // Act + ProcessOutput result = await _gitService.GetDiff(_tempRepositoryAbsolutePath, string.Empty); + + // Assert + result.ExitCode.Should().Be(0); + result.Output.Should().HaveCount(1); + result.Output.First().Should().EndWith($".md"); + } + + [Test] + public async Task GetDiff_WithMultipleCsprojFiles_ReturnsAllModified() + { + // Arrange + string project1Path = Path.Join(_tempRepositoryAbsolutePath, "Project1.csproj"); + string project2Path = Path.Join(_tempRepositoryAbsolutePath, "Project2.csproj"); + + await File.WriteAllTextAsync(project1Path, ""); + await File.WriteAllTextAsync(project2Path, ""); + await ExecuteGitCommand("add ."); + await ExecuteGitCommand("commit -m \"Initial commit\""); + + await File.WriteAllTextAsync(project1Path, ""); + await File.WriteAllTextAsync(project2Path, ""); + await ExecuteGitCommand("add ."); + await ExecuteGitCommand("commit -m \"Update projects\""); + + // Act + ProcessOutput result = await _gitService.GetDiff(_tempRepositoryAbsolutePath, string.Empty); + + // Assert + result.ExitCode.Should().Be(0); + result.Output.Should().HaveCount(2); + result.Output.Any(p => p.EndsWith("Project1.csproj", StringComparison.InvariantCulture)).Should().BeTrue(); + result.Output.Any(p => p.EndsWith("Project2.csproj", StringComparison.InvariantCulture)).Should().BeTrue(); + } + + /// + /// Helper method to execute git commands in the test repository using ProcessExecutor. + /// + private async Task ExecuteGitCommand(string arguments) + { + ProcessOutput result = await _processExecutor.Execute("git", arguments, _tempRepositoryAbsolutePath); + + if (result.ExitCode != 0) + { + throw new InvalidOperationException($"Git command failed: {arguments}. {result.Output}"); + } + } + + /// + /// Deletes a directory with retry logic to handle file locks from Git processes. + /// + private static void DeleteDirectoryWithRetry(string path, int maxRetries = 3) + { + for (int i = 0; i < maxRetries; i++) + { + try + { + Directory.Delete(path, recursive: true); + return; + } + catch (IOException) when (i < maxRetries - 1) + { + // Wait for file handles to be released + Thread.Sleep(100); + } + catch (UnauthorizedAccessException) when (i < maxRetries - 1) + { + // Remove read-only attributes and retry + RemoveReadOnlyAttributes(path); + Thread.Sleep(100); + } + } + } + + /// + /// Removes read-only attributes from all files in a directory recursively. + /// + private static void RemoveReadOnlyAttributes(string path) + { + DirectoryInfo directory = new(path); + + foreach (FileInfo file in directory.GetFiles("*", SearchOption.AllDirectories)) + { + file.Attributes &= ~FileAttributes.ReadOnly; + } + } +} diff --git a/tests/SolarWinds.Changesets.Tests/Publish/PublishChangesetCommandTests.cs b/tests/SolarWinds.Changesets.Tests/Publish/PublishChangesetCommandTests.cs index 5828752..94722b8 100644 --- a/tests/SolarWinds.Changesets.Tests/Publish/PublishChangesetCommandTests.cs +++ b/tests/SolarWinds.Changesets.Tests/Publish/PublishChangesetCommandTests.cs @@ -46,7 +46,7 @@ public void TearDown() [Test] public void PublishChangesetCommand_CompletesSuccesfully_HappyPath() { - _gitServiceMock.Setup(x => x.GetDiff(It.IsAny())).Returns(Task.FromResult(new ProcessOutput(["A.csproj", "B.csproj"], 0))); + _gitServiceMock.Setup(x => x.GetDiff(It.IsAny(), It.IsAny())).Returns(Task.FromResult(new ProcessOutput(["A.csproj", "B.csproj"], 0))); _dotnetServiceMock.Setup(x => x.Pack(It.IsAny())).Returns(Task.FromResult(new ProcessOutput(["Project A packed", "Project B packed"], 0))); _dotnetServiceMock.Setup(x => x.Publish(It.IsAny())).Returns(Task.FromResult(new ProcessOutput(["Project A published", "Project B published"], 0))); @@ -58,7 +58,7 @@ public void PublishChangesetCommand_CompletesSuccesfully_HappyPath() [Test] public void PublishChangesetCommand_WhenNoCsprojToPublish_FinishesWithError() { - _gitServiceMock.Setup(x => x.GetDiff(It.IsAny())).Returns(Task.FromResult(new ProcessOutput([], 0))); + _gitServiceMock.Setup(x => x.GetDiff(It.IsAny(), It.IsAny())).Returns(Task.FromResult(new ProcessOutput([], 0))); CommandAppResult result = _app.Run(); @@ -68,7 +68,7 @@ public void PublishChangesetCommand_WhenNoCsprojToPublish_FinishesWithError() [Test] public void PublishChangesetCommand_WhenPublishFails_FinishesWithError() { - _gitServiceMock.Setup(x => x.GetDiff(It.IsAny())).Returns(Task.FromResult(new ProcessOutput(["A.csproj", "B.csproj"], 0))); + _gitServiceMock.Setup(x => x.GetDiff(It.IsAny(), It.IsAny())).Returns(Task.FromResult(new ProcessOutput(["A.csproj", "B.csproj"], 0))); _dotnetServiceMock.Setup(x => x.Pack(It.IsAny())).Returns(Task.FromResult(new ProcessOutput(["Project A packed", "Project B packed"], 0))); _dotnetServiceMock.Setup(x => x.Publish(It.IsAny())).Returns(Task.FromResult(new ProcessOutput(["Some error ..."], 1))); From 08aa53d07d5653b309fa340ce5e4c7260ceaff1f Mon Sep 17 00:00:00 2001 From: Marek Magath Date: Fri, 2 Jan 2026 10:33:54 +0100 Subject: [PATCH 3/7] Fix Constant condition code scanning alert --- .../Version/Helpers/CsProjectsRepository.cs | 26 ++++++++++++------- src/SolarWinds.Changesets/Shared/Semver.cs | 23 +++++++++++++--- .../Version/CsProjectsRepositoryTests.cs | 13 ++++++---- .../Version/VersionChangesetCommandTests.cs | 6 ++++- 4 files changed, 49 insertions(+), 19 deletions(-) diff --git a/src/SolarWinds.Changesets/Commands/Version/Helpers/CsProjectsRepository.cs b/src/SolarWinds.Changesets/Commands/Version/Helpers/CsProjectsRepository.cs index e7c5f2b..04a5506 100644 --- a/src/SolarWinds.Changesets/Commands/Version/Helpers/CsProjectsRepository.cs +++ b/src/SolarWinds.Changesets/Commands/Version/Helpers/CsProjectsRepository.cs @@ -29,15 +29,9 @@ public CsProject[] GetCsProjects(ChangesetConfig changesetConfig) XDocument csprojXDocument = XDocument.Load(csprojFilePath); string projectName = Path.GetFileNameWithoutExtension(csprojFilePath); - XElement? projectVersionXElement = csprojXDocument.Descendants().SingleOrDefault(d => d.Name.LocalName == "Version"); - Semver? projectVersion = projectVersionXElement is not null - ? Semver.FromString(projectVersionXElement.Value) - : new(0, 0, 0); - - if (projectVersion is null) + Semver projectVersion = GetProjectVersion(csprojXDocument, projectName); + if (projectVersion == Semver.Empty) { - _console.MarkupLine($"[yellow]Version {projectVersionXElement?.Value} could not be parsed " + - $"for project {projectName}. This may have unexpected consequences on the 'version' command![/]"); continue; } @@ -49,7 +43,6 @@ public CsProject[] GetCsProjects(ChangesetConfig changesetConfig) .ToArray(); csProjects.Add(new(projectName, projectVersion, projectReferences, csprojFilePath)); - } return csProjects.ToArray(); @@ -85,6 +78,21 @@ private async Task UpdateCsProjectVersionAsync(string moduleCsProjFilePath, stri await File.WriteAllTextAsync(moduleCsProjFilePath, newVersionContent); } + private Semver GetProjectVersion(XDocument csprojXDocument, string projectName) + { + XElement? projectVersionXElement = csprojXDocument.Descendants().SingleOrDefault(d => d.Name.LocalName == "Version"); + + if (projectVersionXElement is not null && Semver.TryParse(projectVersionXElement.Value, out Semver? parsedVersion)) + { + return parsedVersion; + } + + _console.MarkupLine($"[yellow]Version {projectVersionXElement?.Value} could not be parsed " + + $"for project {projectName}. This may have unexpected consequences on the 'version' command![/]"); + + return Semver.Empty; + } + [GeneratedRegex(@"()(.*?)()")] private static partial Regex VersionRegex(); } diff --git a/src/SolarWinds.Changesets/Shared/Semver.cs b/src/SolarWinds.Changesets/Shared/Semver.cs index cfffb38..7169df6 100644 --- a/src/SolarWinds.Changesets/Shared/Semver.cs +++ b/src/SolarWinds.Changesets/Shared/Semver.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using SystemVersion = System.Version; namespace SolarWinds.Changesets.Shared; @@ -45,13 +46,27 @@ public override string ToString() return $"{Major}.{Minor}.{Patch}"; } - public static Semver? FromString(string version) + /// + /// Attempts to parse a version string into a instance. + /// + /// The version string to parse (e.g., "1.2.3"). + /// When this method returns, contains the parsed if parsing succeeded, or null if parsing failed. + /// True if the version string was successfully parsed; otherwise, false. + public static bool TryParse(string version, [NotNullWhen(true)] out Semver? semver) { - if (!SystemVersion.TryParse(version, out SystemVersion? result)) + if (SystemVersion.TryParse(version, out SystemVersion? result)) { - return null; + semver = new(result.Major, result.Minor, result.Build); + return true; } - return new(result.Major, result.Minor, result.Build); + semver = null; + return false; } + + /// + /// Gets an empty instance with version 0.0.0. + /// + /// A instance representing version 0.0.0. + public static Semver Empty { get; } = new(0, 0, 0); } diff --git a/tests/SolarWinds.Changesets.Tests/Version/CsProjectsRepositoryTests.cs b/tests/SolarWinds.Changesets.Tests/Version/CsProjectsRepositoryTests.cs index 324d586..a6a4b51 100644 --- a/tests/SolarWinds.Changesets.Tests/Version/CsProjectsRepositoryTests.cs +++ b/tests/SolarWinds.Changesets.Tests/Version/CsProjectsRepositoryTests.cs @@ -77,7 +77,7 @@ public async Task UpdateModuleCsProjsAsync_DoesNotTouchProjectFile_WhenNoVersion } [Test] - public void GetCsProjects_OneVersionOneWithoutVersionProjects_ReturnsTwoProjects() + public void GetCsProjects_OnlyOneProjectWithValidVersion_ReturnsSingleProject() { ChangesetConfig config = new() { @@ -88,11 +88,10 @@ public void GetCsProjects_OneVersionOneWithoutVersionProjects_ReturnsTwoProjects CsProjectsRepository csProjFileHelper = new(testConsole); CsProject[] csProjects = csProjFileHelper.GetCsProjects(config); - csProjects.Length.Should().Be(2); + csProjects.Length.Should().Be(1); csProjects - .Where(x => x.Name == "TestProjectWithVersion") - .First() + .Single() .ReferencedProjectNames .Length .Should() @@ -116,8 +115,12 @@ private static void DeleteFile(string path) doc.Load(path); XmlNode? versionNode = doc.DocumentElement?.SelectSingleNode("/Project/PropertyGroup/Version"); + if (versionNode != null && Semver.TryParse(versionNode.InnerText, out Semver? parsedVersion)) + { + return parsedVersion; + } - return versionNode != null ? Semver.FromString(versionNode.InnerText) : null; + return null; } private static string ComputeFileHash(string path) diff --git a/tests/SolarWinds.Changesets.Tests/Version/VersionChangesetCommandTests.cs b/tests/SolarWinds.Changesets.Tests/Version/VersionChangesetCommandTests.cs index 2ae67e6..464ad3d 100644 --- a/tests/SolarWinds.Changesets.Tests/Version/VersionChangesetCommandTests.cs +++ b/tests/SolarWinds.Changesets.Tests/Version/VersionChangesetCommandTests.cs @@ -80,8 +80,12 @@ private static void AssertVersionOutput() doc.Load(path); XmlNode? versionNode = doc.DocumentElement?.SelectSingleNode("/Project/PropertyGroup/Version"); + if (versionNode != null && Semver.TryParse(versionNode.InnerText, out Semver? parsedVersion)) + { + return parsedVersion; + } - return versionNode != null ? Semver.FromString(versionNode.InnerText) : null; + return null; } private static void CopyDirectory(string sourceDirectory, string destinationDirectory, bool recursive, bool firstRun) From b6d283574d6206e96604a9c64392c62df24b7582 Mon Sep 17 00:00:00 2001 From: Marek Magath Date: Fri, 2 Jan 2026 10:36:46 +0100 Subject: [PATCH 4/7] Fix Useless assignment to local variable code scanning alert --- .../Version/VersionChangesetCommandTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/SolarWinds.Changesets.Tests/Version/VersionChangesetCommandTests.cs b/tests/SolarWinds.Changesets.Tests/Version/VersionChangesetCommandTests.cs index 464ad3d..fa5c89f 100644 --- a/tests/SolarWinds.Changesets.Tests/Version/VersionChangesetCommandTests.cs +++ b/tests/SolarWinds.Changesets.Tests/Version/VersionChangesetCommandTests.cs @@ -57,6 +57,8 @@ public void VersionCommand_HappyPath_FolderAndFilesAreCreated() CommandAppResult result = app.Run(); + result.ExitCode.Should().Be(ResultCodes.Success, result.Output); + AssertVersionOutput(); } From 28ae269ee53a8b67d03c42a342eeb2fbd752b6db Mon Sep 17 00:00:00 2001 From: Marek Magath Date: Fri, 2 Jan 2026 10:40:16 +0100 Subject: [PATCH 5/7] Fix Dispose may not be called code scanning alert --- .../Version/CsProjectsRepositoryTests.cs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/tests/SolarWinds.Changesets.Tests/Version/CsProjectsRepositoryTests.cs b/tests/SolarWinds.Changesets.Tests/Version/CsProjectsRepositoryTests.cs index a6a4b51..54eb16a 100644 --- a/tests/SolarWinds.Changesets.Tests/Version/CsProjectsRepositoryTests.cs +++ b/tests/SolarWinds.Changesets.Tests/Version/CsProjectsRepositoryTests.cs @@ -36,7 +36,7 @@ public async Task UpdateModuleCsProjsAsync_IncreasesVersion_WhenVersionInProject ModuleCsProjFilePath = s_testFilePath, Changes = [("abc", BumpType.Minor)] }]; - TestConsole testConsole = new(); + using TestConsole testConsole = new(); CsProjectsRepository csProjFileHelper = new(testConsole); await csProjFileHelper.UpdateCsProjectsVersionAsync(changes); @@ -44,7 +44,6 @@ public async Task UpdateModuleCsProjsAsync_IncreasesVersion_WhenVersionInProject Semver? versionFromFile = LoadVersionFromProjectFile(s_testFilePath); versionFromFile?.Should().BeEquivalentTo(new Semver(1, 1, 0)); - testConsole.Dispose(); } [Test] @@ -65,7 +64,7 @@ public async Task UpdateModuleCsProjsAsync_DoesNotTouchProjectFile_WhenNoVersion Changes = [("abc", BumpType.Minor)] }]; - TestConsole testConsole = new(); + using TestConsole testConsole = new(); CsProjectsRepository csProjFileHelper = new(testConsole); await csProjFileHelper.UpdateCsProjectsVersionAsync(changes); @@ -73,7 +72,6 @@ public async Task UpdateModuleCsProjsAsync_DoesNotTouchProjectFile_WhenNoVersion LoadVersionFromProjectFile(s_testFilePath).Should().BeNull(); ComputeFileHash(s_testFilePath).Should().Be(hashOriginal); - testConsole.Dispose(); } [Test] @@ -84,7 +82,7 @@ public void GetCsProjects_OnlyOneProjectWithValidVersion_ReturnsSingleProject() SourcePath = "TestData" }; - TestConsole testConsole = new(); + using TestConsole testConsole = new(); CsProjectsRepository csProjFileHelper = new(testConsole); CsProject[] csProjects = csProjFileHelper.GetCsProjects(config); @@ -97,8 +95,6 @@ public void GetCsProjects_OnlyOneProjectWithValidVersion_ReturnsSingleProject() .Should() .Be(2) ; - - testConsole.Dispose(); } private static void DeleteFile(string path) From 06bbb5a5bb6db18a8f5e0187ddcb41e5c75d3ff5 Mon Sep 17 00:00:00 2001 From: Marek Magath Date: Fri, 2 Jan 2026 10:41:47 +0100 Subject: [PATCH 6/7] Update changelog and bump version to 0.1.4 --- src/SolarWinds.Changesets/CHANGELOG.md | 9 +++++++++ src/SolarWinds.Changesets/SolarWinds.Changesets.csproj | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/SolarWinds.Changesets/CHANGELOG.md b/src/SolarWinds.Changesets/CHANGELOG.md index ef9a9b2..eb4b04a 100644 --- a/src/SolarWinds.Changesets/CHANGELOG.md +++ b/src/SolarWinds.Changesets/CHANGELOG.md @@ -1,5 +1,14 @@ # SolarWinds.Changesets +## 0.1.4 + +**Patch Changes**: + +- [[#19](https://github.com/solarwinds/net-changesets/issues/19)] Fix publish command ([PR #14](https://github.com/solarwinds/net-changesets/pull/14)) + - The `publish` command now compares the last two commits (HEAD~1 and HEAD) instead of comparing the last commit with the working tree to determine which projects were published. + This change is required because publishing must be done after version bumps and generated files are committed. Since there is no other reliable way to detect whether the version command was run before publish, + the comparison is based on committed changes in `.csproj` files. This is still error-prone if users make manual changes to `.csproj` files and run `publish` command, but it is a reasonable compromise for now. + ## 0.1.3 **Patch Changes**: diff --git a/src/SolarWinds.Changesets/SolarWinds.Changesets.csproj b/src/SolarWinds.Changesets/SolarWinds.Changesets.csproj index 68b08ae..1a47387 100644 --- a/src/SolarWinds.Changesets/SolarWinds.Changesets.csproj +++ b/src/SolarWinds.Changesets/SolarWinds.Changesets.csproj @@ -17,7 +17,7 @@ $(RepositoryUrl)/releases true Exe - 0.1.3 + 0.1.4 embedded true From 39a6d24073f2b305439d74819d25d7a3161fb94e Mon Sep 17 00:00:00 2001 From: Marek Magath Date: Fri, 2 Jan 2026 11:25:22 +0100 Subject: [PATCH 7/7] Update documentation --- docs/commands-implementation-details.md | 33 +++++++++++++++++++++---- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/docs/commands-implementation-details.md b/docs/commands-implementation-details.md index 2f8da1f..dcc0230 100644 --- a/docs/commands-implementation-details.md +++ b/docs/commands-implementation-details.md @@ -53,13 +53,36 @@ If there are any existing changesets the command generates changelogs for every changeset publish ``` -Creates nuget packages of affected projects and publishes them to the predefined package source. The command: +Creates NuGet packages of affected projects and publishes them to the predefined package source. -1. Gets all modified `.csproj` files from the predefined working directory using `git diff --name-only`. -1. Creates nuget package using `nuget pack` for every csproj file. -1. Pushes the created nuget packages to the predefined nuget source using `nuget push`. +### How it works (current .NET implementation) -The package source can be configured via `config.json` file. +Currently, the `publish` command assumes that the changes made by the `version` command have **already been committed**. The intended flow is: + +1. Run `changeset version` to bump versions, update changelogs, and delete processed changesets. +2. Commit the changes produced by the `version` command. +3. Run `changeset publish` to create and push NuGet packages based on the committed changes. + +The `publish` command does following: + +1. Gets all modified `.csproj` files from the predefined source directory by comparing the last two commits `git diff --name-only HEAD~1 HEAD {sourcePath}` +2. For each changed `.csproj` file, creates a NuGet package using `dotnet pack`. +3. Pushes the created NuGet packages to the predefined NuGet source using `dotnet nuget push`. + +The package source can be configured via the `.changeset/config.json` file (see `docs/config-file-options.md`). + +This approach relies on the fact that the `version` command only performs three types of changes: + +- Deleting processed changeset files from the `.changeset` folder +- Modifying or creating `CHANGELOG.md` files +- Bumping versions in `.csproj` files + +By looking at the diff between `HEAD~1` and `HEAD`, `publish` can safely identify which projects had their versions bumped and therefore need to be published. + +### Comparison with original Node.js changesets implementation + +The original `@changesets/cli` implementation for Node.js works differently. Instead of relying on a Git diff, +it checks whether a package with the **current version** from `package.json` already exists in the package registry; if it does not, the package is published. ## `status`