From 2fda26afb5ac133b288baaef1af788389045da04 Mon Sep 17 00:00:00 2001 From: Phil Asmar Date: Wed, 16 Sep 2026 10:37:02 -0400 Subject: [PATCH] feat: print one project's version with version --current --project-name --- .../d496df5b-8330-4f44-9484-47181d9fe909.json | 14 +++++ README.md | 23 ++++++++ src/AutoVer/Commands/CommandFactory.cs | 13 ++++- src/AutoVer/Commands/VersionCommand.cs | 48 +++++++++++++++-- .../VersionCommandTests.cs | 54 +++++++++++++++++++ 5 files changed, 148 insertions(+), 4 deletions(-) create mode 100644 .autover/changes/d496df5b-8330-4f44-9484-47181d9fe909.json diff --git a/.autover/changes/d496df5b-8330-4f44-9484-47181d9fe909.json b/.autover/changes/d496df5b-8330-4f44-9484-47181d9fe909.json new file mode 100644 index 0000000..56035c0 --- /dev/null +++ b/.autover/changes/d496df5b-8330-4f44-9484-47181d9fe909.json @@ -0,0 +1,14 @@ +{ + "Projects": [ + { + "Name": "AutoVer", + "Type": "Minor", + "ChangelogMessages": [ + "Add --project-name to `version --current`, so one project's version can be captured from a repository that has several: VERSION=$(autover version --current --project-name \"My Image\") prints a bare value however many projects are configured. Previously only a single-project repository produced capturable output - with several, --current prints them labeled, which is readable but not assignable to a variable", + "An unknown --project-name is an error that lists the configured project names, rather than empty output. Left unvalidated, a typo in a pipeline would set VERSION=\"\" and publish an untagged or mis-tagged artifact", + "Naming a project whose Paths have drifted to different versions is an error rather than a silent pick of the first, since those files are configured to share one version", + "The name matches the same way `change --project-name` does: exact and case-sensitive" + ] + } + ] +} diff --git a/README.md b/README.md index eb0933d..d2aea2e 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ tags the release. | `--increment-type ` | `Major`, `Minor`, `Patch` or `None`. Overrides the configured default. | | `--use-version ` | Release a specific version instead of an increment. | | `--current` | Print the current version without changing anything. | +| `--project-name ` | With `--current`, print only this project's version, as a bare value. | | `--skip-version-tag-check` | Don't require each project to already carry a version. | | `--no-commit` | Bump the version without committing (implies no tag). | | `--no-tag` | Commit the bump without tagging. | @@ -78,6 +79,28 @@ tags the release. `--current` prints a bare value for a single project, so it can be captured directly: `VERSION=$(autover version --current)`. +With several projects it prints them labeled instead, since there is no single +"the" version to capture: + +``` +Project1: 1.2.3 +Project2: 4.5.6 +``` + +Name one to get a bare value back, which is what a pipeline publishing several +artifacts from one repository needs: + +```bash +VERSION=$(autover version --current --project-name "Project2") # 4.5.6 +``` + +The name matches the `Name` in `Projects`, exactly as `change --project-name` +does, and an unknown name is an error listing the configured ones rather than +empty output - a typo in a pipeline would otherwise set `VERSION=""` and publish +a mis-tagged artifact. A project using `Paths` shares one version across its +files, so naming it prints that; if those files have drifted apart, that is an +error too rather than a silent pick. + ### `autover changelog` Generates `CHANGELOG.md` for the current release and commits it. diff --git a/src/AutoVer/Commands/CommandFactory.cs b/src/AutoVer/Commands/CommandFactory.cs index 496d2b5..f71e73b 100644 --- a/src/AutoVer/Commands/CommandFactory.cs +++ b/src/AutoVer/Commands/CommandFactory.cs @@ -104,6 +104,15 @@ private Command BuildVersionCommand() { Description = "Print the current version of each project and exit, without incrementing, committing, or tagging anything." }; + // Only meaningful alongside --current. With several projects, --current prints them + // labeled, which is readable but not capturable - a caller wanting one version in a + // variable had no way to ask for it. Naming the project makes it print bare, so + // VERSION=$(autover version --current --project-name "My Image") works with any number + // of projects. Same option name and matching rules as `change --project-name`. + Option projectNameOption = new("--project-name") + { + Description = "With --current, print only this project's version, as a bare value. Without --current, ignored." + }; versionCommand.Add(OptionProjectPath); versionCommand.Add(OptionIncrementType); @@ -112,6 +121,7 @@ private Command BuildVersionCommand() versionCommand.Add(noTagOption); versionCommand.Add(useVersionOption); versionCommand.Add(currentOption); + versionCommand.Add(projectNameOption); versionCommand.Add(OptionVerbose); versionCommand.SetAction((parseResult, cancellationToken) => ExecuteCommandAsync(parseResult, async () => @@ -123,6 +133,7 @@ private Command BuildVersionCommand() var optionNoTag = parseResult.GetValue(noTagOption); var optionUseVersion = parseResult.GetValue(useVersionOption); var optionCurrent = parseResult.GetValue(currentOption); + var optionProjectName = parseResult.GetValue(projectNameOption); var command = new VersionCommand( projectHandler, @@ -132,7 +143,7 @@ private Command BuildVersionCommand() versionHandler, versionIncrementer, toolInteractiveService); - await command.ExecuteAsync(optionProjectPath, optionIncrementType, optionSkipVersionTagCheck, optionNoCommit, optionNoTag, optionUseVersion, optionCurrent); + await command.ExecuteAsync(optionProjectPath, optionIncrementType, optionSkipVersionTagCheck, optionNoCommit, optionNoTag, optionUseVersion, optionCurrent, optionProjectName); })); return versionCommand; diff --git a/src/AutoVer/Commands/VersionCommand.cs b/src/AutoVer/Commands/VersionCommand.cs index bbae5a3..6c42b43 100644 --- a/src/AutoVer/Commands/VersionCommand.cs +++ b/src/AutoVer/Commands/VersionCommand.cs @@ -20,7 +20,8 @@ public async Task ExecuteAsync( bool optionNoCommit, bool optionNoTag, string? optionUseVersion, - bool optionCurrent) + bool optionCurrent, + string? optionProjectName = null) { var incrementType = IncrementTypeParser.Parse(optionIncrementType); @@ -28,7 +29,7 @@ public async Task ExecuteAsync( if (optionCurrent) { - PrintCurrentVersions(userConfiguration); + PrintCurrentVersions(userConfiguration, optionProjectName); return; } @@ -295,8 +296,23 @@ private static IncrementType ResolveIncrementTypeFromTag( // `changelog --release-name`'s convention for shell capture, e.g. // VERSION=$(autover version --current). Multiple projects print // labeled, since there's no single "the" version to capture bare. - private void PrintCurrentVersions(UserConfiguration userConfiguration) + private void PrintCurrentVersions(UserConfiguration userConfiguration, string? projectName = null) { + // Naming a project makes the output bare regardless of how many projects there are, so a + // caller can capture one version - VERSION=$(autover version --current --project-name X). + // Resolved before the VersionFromTag branch below so an unknown name is always an error + // rather than silently printing the repository version. + ProjectContainer? requestedProject = null; + if (!string.IsNullOrEmpty(projectName)) + { + // Same matching as `change --project-name`: exact, case-sensitive. + requestedProject = userConfiguration.Projects.FirstOrDefault(x => x.Name.Equals(projectName)); + if (requestedProject is null) + throw new InvalidProjectNameSpecifiedException( + $"The project '{projectName}' does not exist. Please specify a valid project name. " + + $"Configured: {string.Join(", ", userConfiguration.Projects.Select(x => $"'{x.Name}'"))}."); + } + if (userConfiguration.VersionFromTag) { var tagVersion = versionHandler.GetCurrentTagVersion(userConfiguration); @@ -305,10 +321,36 @@ private void PrintCurrentVersions(UserConfiguration userConfiguration) $"The Git repository '{userConfiguration.GitRoot}' has no release tag to read a version from yet. " + "Please run 'autover version' first."); + // One tag carries one version, so every project reports the same thing here - naming + // one is redundant but not wrong, and validating it above still catches a typo. toolInteractiveService.WriteLine(tagVersion.ToString()); return; } + if (requestedProject is not null) + { + var requestedVersions = requestedProject.Projects + .Select(project => project.ProjectDefinition.Version) + .Distinct() + .ToList(); + + if (requestedVersions.Count == 0) + throw new InvalidProjectNameSpecifiedException( + $"The project '{projectName}' has no project files to read a version from."); + + // A container's files share one version by definition (that is what Paths means), so + // disagreement means one was edited by hand. Printing the first would quietly publish + // a version that does not match what is in the other file. + if (requestedVersions.Count > 1) + throw new InvalidProjectException( + $"The files of project '{projectName}' carry different versions " + + $"({string.Join(", ", requestedVersions)}), so there is no single version to print. " + + "They are configured to share one - reconcile them, or list them as separate projects."); + + toolInteractiveService.WriteLine(requestedVersions[0]); + return; + } + var allProjects = userConfiguration.Projects .SelectMany(container => container.Projects) .ToList(); diff --git a/test/AutoVer.IntegrationTests/VersionCommandTests.cs b/test/AutoVer.IntegrationTests/VersionCommandTests.cs index b482582..2dccfdb 100644 --- a/test/AutoVer.IntegrationTests/VersionCommandTests.cs +++ b/test/AutoVer.IntegrationTests/VersionCommandTests.cs @@ -388,6 +388,60 @@ public async Task Current_PrintsVersionWithoutMutating() await Assert.That(GitUtilities.GetAllTags(_tempDir)).IsEmpty(); } + // M. With several projects, `--current` labels each one. Asserted so the bare-value + // contract in L stays specific to the single-project case: a caller doing + // VERSION=$(autover version --current) on a multi-project repository gets labeled lines, + // not a version, which is exactly why --project-name in N exists. + [Test] + public async Task Current_WithSeveralProjects_LabelsEachVersion() + { + await SetUpTwoProjectRepo("Project1", "1.2.3", "Project2", "4.5.6", useSameVersionForAllProjects: false); + + var (exitCode, output, _) = await AutoVerUtilities.RunCapturingOutput(["version", "--project-path", _tempDir, "--current"]); + + await Assert.That(exitCode).IsEqualTo(CommandReturnCodes.Success); + await Assert.That(output).Contains("Project1: 1.2.3"); + await Assert.That(output).Contains("Project2: 4.5.6"); + } + + // N. `--current --project-name` prints that one project's version bare, however many + // projects exist. This is what makes VERSION=$(autover version --current --project-name X) + // work in a pipeline that publishes several artifacts from one repository - previously the + // only capturable output was a single-project repository's. + [Test] + public async Task CurrentWithProjectName_PrintsThatProjectBare() + { + await SetUpTwoProjectRepo("Project1", "1.2.3", "Project2", "4.5.6", useSameVersionForAllProjects: false); + + var (exitCode, output, _) = await AutoVerUtilities.RunCapturingOutput( + ["version", "--project-path", _tempDir, "--current", "--project-name", "Project2"]); + + await Assert.That(exitCode).IsEqualTo(CommandReturnCodes.Success); + await Assert.That(output.Trim()).IsEqualTo("4.5.6"); + // Bare means bare: no label, and nothing about the project it was not asked about. + await Assert.That(output).DoesNotContain("Project2:"); + await Assert.That(output).DoesNotContain("1.2.3"); + } + + // O. An unknown --project-name is an error rather than empty output. Left unvalidated, a + // typo in a pipeline would set VERSION="" and publish an untagged or mis-tagged artifact. + [Test] + public async Task CurrentWithUnknownProjectName_FailsAndListsConfiguredProjects() + { + await SetUpTwoProjectRepo("Project1", "1.2.3", "Project2", "4.5.6", useSameVersionForAllProjects: false); + + var (exitCode, output, error) = await AutoVerUtilities.RunCapturingOutput( + ["version", "--project-path", _tempDir, "--current", "--project-name", "DoesNotExist"]); + + await Assert.That(exitCode).IsEqualTo(CommandReturnCodes.UserError); + await Assert.That(error).Contains("DoesNotExist"); + // The message names what IS configured, so the fix does not need a second command. + await Assert.That(error).Contains("Project1"); + await Assert.That(error).Contains("Project2"); + await Assert.That(error).DoesNotContain("at AutoVer."); + await Assert.That(output.Trim()).IsEmpty(); + } + // `changeFilesDetermineIncrementType` must be true for scenarios that rely on a change // file to pick the increment type (B/C/D/K), and false for scenarios with no change // file at all (G/H/I/J) — with it true and no change file, VersionCommand resolves the