From bf3d969ef2dbc615bd528f559920bcf532dda910 Mon Sep 17 00:00:00 2001 From: Boris Kolpackov Date: Sat, 15 Dec 2018 14:45:24 +0200 Subject: Implement bdep-release that manages project's version during release --- bdep/bdep.cli | 5 + bdep/bdep.cxx | 2 + bdep/buildfile | 2 + bdep/git.hxx | 2 + bdep/git.txx | 9 +- bdep/project.cxx | 6 +- bdep/project.hxx | 17 +- bdep/release.cli | 166 ++++++++++ bdep/release.cxx | 842 +++++++++++++++++++++++++++++++++++++++++++++++ bdep/release.hxx | 19 ++ bdep/sync.cxx | 17 +- bdep/utility.hxx | 25 +- bdep/utility.txx | 30 +- doc/cli.sh | 4 +- tests/common.testscript | 1 + tests/release.testscript | 634 +++++++++++++++++++++++++++++++++++ 16 files changed, 1749 insertions(+), 32 deletions(-) create mode 100644 bdep/release.cli create mode 100644 bdep/release.cxx create mode 100644 bdep/release.hxx create mode 100644 tests/release.testscript diff --git a/bdep/bdep.cli b/bdep/bdep.cli index 2b8b278..c743686 100644 --- a/bdep/bdep.cli +++ b/bdep/bdep.cli @@ -448,6 +448,11 @@ namespace bdep "\l{bdep-ci(1)} \- submit project test request to CI server" } + bool release + { + "\l{bdep-release(1)} \- manage project's version during release" + } + bool publish { "\l{bdep-publish(1)} \- publish project to archive repository" diff --git a/bdep/bdep.cxx b/bdep/bdep.cxx index 8fbcd71..e6f850e 100644 --- a/bdep/bdep.cxx +++ b/bdep/bdep.cxx @@ -27,6 +27,7 @@ #include #include #include +#include #include #include #include @@ -298,6 +299,7 @@ try COMMAND_IMPL (fetch, fetch, "fetch", true); COMMAND_IMPL (status, status, "status", true); COMMAND_IMPL (ci, ci, "ci", true); + COMMAND_IMPL (release, release, "release", true); COMMAND_IMPL (publish, publish, "publish", true); COMMAND_IMPL (deinit, deinit, "deinit", true); COMMAND_IMPL (config, config, "config", true); diff --git a/bdep/buildfile b/bdep/buildfile index f77282a..274700e 100644 --- a/bdep/buildfile +++ b/bdep/buildfile @@ -28,6 +28,7 @@ sync-options \ fetch-options \ status-options \ ci-options \ +release-options \ publish-options \ deinit-options \ config-options \ @@ -79,6 +80,7 @@ if $cli.configured cli.cxx{fetch-options}: cli{fetch} cli.cxx{status-options}: cli{status} cli.cxx{ci-options}: cli{ci} + cli.cxx{release-options}: cli{release} cli.cxx{publish-options}: cli{publish} cli.cxx{deinit-options}: cli{deinit} cli.cxx{config-options}: cli{config} diff --git a/bdep/git.hxx b/bdep/git.hxx index 91a9fd9..ee8847c 100644 --- a/bdep/git.hxx +++ b/bdep/git.hxx @@ -91,6 +91,8 @@ namespace bdep bool behind = false; // Local branch is behind of upstream. }; + // Note: requires git 2.11.0 or higher. + // git_repository_status git_status (const dir_path& repo); } diff --git a/bdep/git.txx b/bdep/git.txx index 4abae3c..c4202de 100644 --- a/bdep/git.txx +++ b/bdep/git.txx @@ -8,9 +8,16 @@ namespace bdep void run_git (const semantic_version& min_ver, const dir_path& repo, A&&... args) { + // We don't expect git to print anything to stdout, as the caller would use + // start_git() and pipe otherwise. Thus, let's redirect stdout to stderr + // for good measure, as git is known to print some informational messages + // to stdout. + // process pr (start_git (min_ver, repo, - 0 /* stdin */, 1 /* stdout */, 2 /* stderr */, + 0 /* stdin */, + 2 /* stdout */, + 2 /* stderr */, forward (args)...)); finish_git (pr); diff --git a/bdep/project.cxx b/bdep/project.cxx index 8178b01..f6ca5bf 100644 --- a/bdep/project.cxx +++ b/bdep/project.cxx @@ -263,15 +263,15 @@ namespace bdep } project_packages - find_project_packages (const project_options& po, + find_project_packages (const dir_paths& dirs, bool ignore_packages, bool load_packages) { project_packages r; - if (po.directory_specified ()) + if (!dirs.empty ()) { - for (const dir_path& d: po.directory ()) + for (const dir_path& d: dirs) { project_package p (find_project_package (d)); diff --git a/bdep/project.hxx b/bdep/project.hxx index e508374..c252ead 100644 --- a/bdep/project.hxx +++ b/bdep/project.hxx @@ -205,11 +205,26 @@ namespace bdep package_locations packages; }; + // Search project packages in the specified directories or the current + // directory if none were specified. + // project_packages - find_project_packages (const project_options&, + find_project_packages (const dir_paths&, bool ignore_packages, bool load_packages = true); + inline project_packages + find_project_packages (const project_options& po, bool ip, bool lp = true) + { + return find_project_packages (po.directory (), ip, lp); + } + + inline dir_path + find_project (const dir_paths& dirs) + { + return find_project_packages (dirs, true /* ignore_packages */).project; + } + inline dir_path find_project (const project_options& o) { diff --git a/bdep/release.cli b/bdep/release.cli new file mode 100644 index 0000000..10c4b58 --- /dev/null +++ b/bdep/release.cli @@ -0,0 +1,166 @@ +// file : bdep/release.cli +// copyright : Copyright (c) 2014-2018 Code Synthesis Ltd +// license : MIT; see accompanying LICENSE file + +include ; + +"\section=1" +"\name=bdep-release" +"\summary=manage project's version during release" + +namespace bdep +{ + { + " + + ", + + "\h|SYNOPSIS| + + \c{\b{bdep release} [] []} + + \c{ = \b{--directory}|\b{-d} | \n + = (\b{--directory}|\b{-d} )...} + + \h|DESCRIPTION| + + The \cb{release} command manages the project's version during the + release. Specifically, it first changes the snapshot version to the + corresponding release version in each project package's \cb{manifest} + file, commits these changes (unless \cb{--no-commit} is specified), tags + this commit (unless \cb{--no-tag} is specified), and, if \cb{--push} is + specified, pushes the changes to the remote. Unless \cb{--no-open} is + specified, the \cb{release} command then opens the next development cycle + by changing the version to a snapshot, committing these changes (unless + \cb{--no-commit} is specified), and, if \cb{--push} is specified, pushing + them to the remote. Note that committing, tagging, and pushing is + currently only supported for \cb{git(1)} project repositories. + + The \cb{release} command can also be used to release a new package + revision by passing the \cb{--revision} option. In this mode \cb{release} + increments the current version's revision component in each project + package's \cb{manifest} file, commits these changes (unless + \cb{--no-commit} is specified), tags this commit (unless \cb{--no-tag} is + specified), and, if \cb{--push} is specified, pushes the changes to the + remote. Note that in this case the project's repository index is expected + to already contain other changes since for a revision all the associated + changes, including to version, must belong to a single commit. In this + mode \cb{release} will also silently replace an existing tag for the same + version. + + The \cb{release} command also has a number of \i{continue modes} that + allow the completion of steps that were previously suppressed with the + \cb{--no-*} options in the above main modes. These are \cb{--tag} which + tags the release commit and, if \cb{--push} is specified, pushes it to + the remote as well as \cb{--open} which performs the opening of the next + development cycle as described above. + + Normally, \cb{release} operates on all the packages in a project. If no + project directory is specified, then the current working directory is + assumed and all the packages are released, even if the current directory + is a package directory. If, however, one or more package directories are + specified explicitly with \c{\b{--directory}|\b{-d}}, then \cb{release} + assumes you know what you are doing and only releases these packages. + All the packages being released must have the same version but may have + different revisions. + " + } + + class cmd_release_options: common_options + { + "\h|RELEASE OPTIONS|" + + bool --revision + { + "Release a new package revision instead of a new version." + } + + bool --no-commit + { + "Don't commit the changes. Implies \cb{--no-tag} and, in the version + release mode, \cb{--no-open}." + } + + bool --no-tag + { + "Don't tag the release commit. Tagging can be performed later using the + \cb{--tag} mode option." + } + + bool --tag + { + "Tag the already released version instead of releasing a new one." + } + + bool --push + { + "Push the committed changes and tags to the remote." + } + + bool --no-open + { + "Don't open the next development cycle. Opening can be performed later + using the \cb{--open} mode option." + } + + bool --open + { + "Open the next development cycle instead of releasing a new version." + } + + bool --alpha + { + "Release an alpha instead of the final version." + } + + bool --beta + { + "Release a beta version instead of the final version." + } + + bool --minor + { + "Release the next minor version instead of the current patch." + } + + bool --major + { + "Release the next major version instead of the current minor or patch." + } + + bool --open-beta + { + "Open the development cycle with the next beta version." + } + + bool --open-patch + { + "Open the development cycle with the next patch version. This is the + default if the current patch version is not \c{0} (bugfix release + series)." + } + + bool --open-minor + { + "Open the development cycle with the next minor version. This is the + default if the current patch version is \c{0} (feature release series)." + } + + bool --open-major + { + "Open the development cycle with the next major version." + } + + bool --yes|-y + { + "Don't prompt for confirmation before releasing." + } + + dir_paths --directory|-d + { + "", + "Assume project/package is in the specified directory rather than in the + current working directory." + } + }; +} diff --git a/bdep/release.cxx b/bdep/release.cxx new file mode 100644 index 0000000..8c2e496 --- /dev/null +++ b/bdep/release.cxx @@ -0,0 +1,842 @@ +// file : bdep/release.cxx -*- C++ -*- +// copyright : Copyright (c) 2014-2018 Code Synthesis Ltd +// license : MIT; see accompanying LICENSE file + +#include + +#include // manifest_name_value +#include + +#include + +#include +#include +#include + +using namespace std; +using namespace butl; + +namespace bdep +{ + // Note: the git_status() function, that we use, requires git 2.11.0 or + // higher. + // + static const semantic_version git_ver {2, 11, 0}; + + // Project/package information relevant to what we are doing here. + // + // Absent optional values mean the corresponding step does not make sense in + // this mode or it was suppressed with one of the --no-* options. + // + // Note that while the release versions can vary in revisions, the open + // version is always the same. + // + namespace + { + struct package + { + package_name name; + path manifest; // Absolute path. + + // File position of the version value for manifest rewriting. + // + manifest_name_value version_pos; + + standard_version current_version; + optional release_version; + }; + + struct project + { + dir_path path; + vector packages; + + optional open_version; + + optional tag; + bool replace_tag = false; + }; + } + + // The plan_*() functions calculate and set all the new values in the passed + // project but don't apply any changes. + // + static void + plan_tag (const cmd_release_options& o, project& prj) + { + // While there is nothing wrong with having uncommitted changes while + // tagging, in our case we may end up with a wrong tag if the version in + // the (modified) manifest does not correspond to the latest commit. + // + // Note that if we are called as part of releasing a new revision, then + // the project will have some changes staged (see plan_revision() for + // details). + // + { + git_repository_status s (git_status (prj.path)); + + if ((s.staged && !o.revision ()) || s.unstaged) + fail << "project directory has uncommitted changes" << + info << "run 'git status' for details"; + } + + // All the versions are the same sans the revision. Note that our version + // can be either current (--tag mode) or release (part of the release). + // + const package& pkg (prj.packages.front ()); // Exemplar package. + const standard_version& cv (pkg.release_version + ? *pkg.release_version + : pkg.current_version); + + if (cv.snapshot ()) + fail << "current version " << cv << " is a snapshot"; + + // Canonical version tag without epoch or revision. + // + prj.tag = "v" + cv.string_project (); + + // Replace the existing tag only if this is a revision. + // + prj.replace_tag = (cv.revision != 0); + } + + static void + plan_open (const cmd_release_options& o, project& prj) + { + // There could be changes already added to the index but otherwise the + // repository should be clean. + // + { + git_repository_status s (git_status (prj.path)); + + if (s.unstaged) + fail << "project directory has unstaged changes" << + info << "run 'git status' for details"; + } + + // All the versions are the same sans the revision. Note that our version + // can be either current (--open mode) or release (part of the release). + // + const package& pkg (prj.packages.front ()); // Exemplar package. + const standard_version& cv (pkg.release_version + ? *pkg.release_version + : pkg.current_version); + + // Change the version to the next snapshot. Similar to the release part, + // here we have sensible defaults as well as user input: + // + // 1.2.0 -> 1.3.0-a.0.z + // -> 1.2.1-a.0.z --open-patch + // -> 2.0.0-a.0.z --open-major + // + // 1.2.3 -> 1.2.4-a.0.z (assuming bugfix branch) + // -> 1.3.0-a.0.z --open-minor + // -> 2.0.0-a.0.z --open-major + // + // 1.2.0-a.1 -> 1.2.0-a.1.z + // -> 1.2.0-b.0.z --open-beta + // + // 1.2.0-b.1 -> 1.2.0-b.1.z + // + // Note that there is no --open-alpha since that's either the default or + // would be invalid (jumping backwards). + // + auto& ov (prj.open_version); + + auto make_snapshot = [&cv] (uint16_t major, + uint16_t minor, + uint16_t patch, + uint16_t pre_release = 0 /* a.0 */) + { + return standard_version (cv.epoch, + major, + minor, + patch, + pre_release, + standard_version::latest_sn, + "" /* snapshot_id */); + }; + + if (cv.snapshot ()) + { + // The cv variable refers the current version as the release version + // cannot be a snapshot. + // + assert (!pkg.release_version); + + fail << "current version " << cv << " is a snapshot"; + } + else if (cv.alpha ()) + { + if (const char* n = (o.open_patch () ? "--open-patch" : + o.open_minor () ? "--open-minor" : + o.open_major () ? "--open-major" : nullptr)) + fail << n << " specified for alpha current version " << cv; + + ov = make_snapshot (cv.major (), + cv.minor (), + cv.patch (), + o.open_beta () ? 500 : cv.pre_release ()); + } + else if (cv.beta ()) + { + if (const char* n = (o.open_patch () ? "--open-patch" : + o.open_minor () ? "--open-minor" : + o.open_major () ? "--open-major" : nullptr)) + fail << n << " specified for beta current version " << cv; + + ov = make_snapshot (cv.major (), + cv.minor (), + cv.patch (), + cv.pre_release () + 500); + } + else + { + if (const char* n = (o.open_beta () ? "--open-beta" : nullptr)) + fail << n << " specified for final current version " << cv; + + uint16_t mj (cv.major ()); + uint16_t mi (cv.minor ()); + uint16_t pa (cv.patch ()); + + if (o.open_major ()) {mj++; mi = pa = 0;} + else if (o.open_minor ()) { mi++; pa = 0;} + else if (o.open_patch ()) { pa++; } + else if (pa == 0) { mi++; } // Default open minor. + else { pa++; } // Default open patch. + + ov = make_snapshot (mj, mi, pa); + } + } + + static void + plan_version (const cmd_release_options& o, project& prj) + { + // There could be changes already added to the index but otherwise the + // repository should be clean. + // + { + git_repository_status s (git_status (prj.path)); + + if (s.unstaged) + fail << "project directory has unstaged changes" << + info << "run 'git status' for details"; + } + + // All the current versions are the same sans the revision. + // + const standard_version& cv (prj.packages.front ().current_version); + + // Change the release version to the next (pre-)release. + // + standard_version rv; + if (cv.snapshot ()) + { + // To recap, a snapshot is expressed as a version *after* the previous + // pre-release or after an imaginary a.0, if it was the final release. + // Which means we cannot know for certain what the next (pre-)release + // version should be. However, the final release seems like a reasonable + // default. Consider: + // + // 1.2.3-a.0.z -> 1.2.3 (without pre-release) + // 1.2.3-a.2.z -> 1.2.3 (a.2 is last pre-release) + // 1.2.3-b.1.z -> 1.2.3 (b.1 is last pre-release) + // + // And the alternatives would be to release an alpha, a beta, or to jump + // to the next minor (if we opened with a patch increment) or major (if + // we opened with a minor or patch increment) version. + // + // Note that there is no --patch since we cannot jump to patch. + // + uint16_t mj (cv.major ()); + uint16_t mi (cv.minor ()); + uint16_t pa (cv.patch ()); + uint16_t pr (cv.pre_release ()); + + if (o.major ()) {mj++; mi = pa = pr = 0;} + else if (o.minor ()) { mi++; pa = pr = 0;} + else if (o.beta ()) + { + pr = (cv.beta () ? pr : 0) + 1 + 500; // Next/first beta. + } + else if (o.alpha ()) + { + if (cv.beta ()) + fail << "--alpha specified for beta current version " << cv; + + pr++; // Next alpha. + } + else + pr = 0; // Final. + + rv = standard_version (cv.epoch, mj, mi, pa, pr); + } + else if (cv.alpha () || cv.beta ()) + { + // Releasing from alpha/beta. For example, alpha/beta becomes the final + // release without going through a snapshot. + // + if (const char* n = (o.minor () ? "--minor" : + o.major () ? "--major" : nullptr)) + fail << n << " specified for " << (cv.beta () ? "beta" : "alpha") + << " current version " << cv; + + uint16_t pr (cv.pre_release ()); + + if (o.beta ()) + { + pr = (cv.beta () ? pr : 0) + 1 + 500; // Next/first beta. + } + else if (o.alpha ()) + { + if (cv.beta ()) + fail << "--alpha specified for beta current version " << cv; + + pr++; // Next alpha. + } + else + pr = 0; // Final. + + rv = standard_version (cv.epoch, + cv.major (), + cv.minor (), + cv.patch (), + pr); + } + else + fail << "current version " << cv << " is not a snapshot or pre-release"; + + // Update the version in each package. + // + for (package& p: prj.packages) + p.release_version = rv; + + // If we are not committing, then the rest doesn't apply. + // + if (o.no_commit ()) + return; + + if (!o.no_tag ()) + plan_tag (o, prj); + + if (!o.no_open ()) + plan_open (o, prj); + } + + static void + plan_revision (const cmd_release_options& o, project& prj) + { + // There must be changes already added to the index but otherwise the + // repository should be clean. + // + { + git_repository_status s (git_status (prj.path)); + + if (s.unstaged) + fail << "project directory has unstaged changes" << + info << "run 'git status' for details"; + + if (!s.staged) + fail << "project directory has no staged changes" << + info << "revision increment must be committed together with " + << "associated changes"; + } + + // All the current versions are the same sans the revision. + // + const standard_version& cv (prj.packages.front ().current_version); + + if (cv.snapshot ()) + fail << "current version " << cv << " is a snapshot"; + + // Increment the revision in each package manifest. + // + for (package& p: prj.packages) + { + p.release_version = p.current_version; + p.release_version->revision++; + } + + // If we are not committing, then the rest doesn't apply. + // + if (o.no_commit ()) + return; + + if (!o.no_tag ()) + plan_tag (o, prj); + } + + int + cmd_release (const cmd_release_options& o, cli::scanner&) + { + // Detect options incompatibility going through the groups of mutually + // exclusive options. Also make sure that options make sense for the + // current mode (releasing, revising, etc.) by pre-setting an incompatible + // mode option (mopt) as the first group members. + // + { + // Points to the group option, if any is specified and NULL otherwise. + // + const char* gopt (nullptr); + + // If an option is specified on the command line, then check that no + // group option have been specified yet and set it, if that's the case, + // or fail otherwise. + // + auto verify = [&gopt] (const char* opt, bool specified) + { + if (specified) + { + if (gopt == nullptr) + gopt = opt; + else + fail << "both " << gopt << " and " << opt << " specified"; + } + }; + + // Check the mode options. + // + verify ("--revision", o.revision ()); + verify ("--open", o.open ()); + verify ("--tag", o.tag ()); + + // The current mode option (--revision, --open, --tag, or NULL for the + // releasing mode). + // + // Prior to verifying an option group we will be setting gopt to the + // current mode option if the group options are meaningless for this + // mode or to NULL otherwise. + // + const char* mopt (gopt); + + // The following (mutually exclusive) options are only meaningful for + // the releasing mode. + // + gopt = mopt; + verify ("--alpha", o.alpha ()); + verify ("--beta", o.beta ()); + verify ("--minor", o.minor ()); + verify ("--major", o.major ()); + + // The following option is only meaningful for the releasing mode. + // + gopt = mopt; + verify ("--no-open", o.no_open ()); + + // The following option is only meaningful for the releasing and + // revising modes. + // + gopt = o.revision () ? nullptr : mopt; + verify ("--no-tag", o.no_tag ()); + + // The following option is only meaningful for the releasing, revising, + // and opening modes. + // + gopt = o.revision () || o.open () ? nullptr : mopt; + verify ("--no-commit", o.no_commit ()); + + // The following (mutually exclusive) options are only meaningful for + // the releasing and opening modes. + // + gopt = o.open () ? nullptr : mopt; + verify ("--open-beta", o.open_beta ()); + verify ("--open-patch", o.open_patch ()); + verify ("--open-minor", o.open_minor ()); + verify ("--open-major", o.open_major ()); + verify ("--no-open", o.no_open ()); // Releasing only (see above). + + // There is no sense to push without committing the version change first. + // + gopt = nullptr; + verify ("--push", o.push ()); // Meaningful for all modes. + verify ("--no-commit", o.no_commit ()); // See above for modes. + } + + // Fully parse package manifest verify it is valid and returning the + // position of the version value. In a sense we are publishing (by + // tagging) to a version control-based repository and it makes sense to + // ensure the repository will not be broken, similar to how we do it in + // publish. + // + auto parse_manifest = [] (const path& f) + { + manifest_name_value r; + + auto m (bdep::parse_manifest ( + f, + "package", + false /* ignore_unknown */, + [&r] (manifest_name_value& nv) + { + if (nv.name == "version") + r = nv; + + return true; + })); + + // Validate the *-file manifest values expansion. + // + m.load_files ([&f] (const string& n, const path& p) + { + path vf (f.directory () / p); + + try + { + ifdstream is (vf); + string s (is.read_text ()); + + if (s.empty ()) + fail << n << " manifest value in " << f << " references empty " + << "file " << vf; + + return s; + } + catch (const io_error& e) + { + fail << "unable to read " << vf << " referenced by " << n + << " manifest value in " << f << endf; + } + }); + + assert (!r.empty ()); + r.value = m.version.string (); // For good measure. + return r; + }; + + // Collect project/package information. + // + project prj; + { + // We publish all the packages in the project. We could have required a + // configuration and verified that they are all initialized, similar to + // publish. But seeing that we don't need the configuration (unlike + // publish), this feels like an unnecessary complication. We also don't + // pre-sync for the same reasons. + // + // Seeing that we are tagging the entire repository, force the + // collection of all the packages even if the current working directory + // is a package. But allow explicit package directory specification as + // the "I know what I am doing" mode (e.g., to sidestep the same version + // restriction). + // + package_locations pls; + + if (o.directory_specified ()) + { + project_packages pp ( + find_project_packages (o.directory (), + false /* ignore_packages */, + true /* load_packages */)); + prj.path = move (pp.project); + pls = move (pp.packages); + } + else + { + prj.path = find_project (o.directory ()); + pls = load_packages (prj.path); + } + + for (package_location& pl: pls) + { + // Parse each manifest extracting name, version, position, etc. + // + path f (prj.path / pl.path / manifest_file); + manifest_name_value vv (parse_manifest (f)); + + package_name n (move (pl.name)); + standard_version v; + try + { + // Allow stubs only in the --revision mode. + // + standard_version::flags f (standard_version::none); + + if (o.revision ()) + f |= standard_version::allow_stub; + + v = standard_version (vv.value, f); + } + catch (const invalid_argument&) + { + fail << "current package " << n << " version " << vv.value + << " is not standard"; + } + + prj.packages.push_back ( + package {move (n), + move (f), + move (vv), + move (v), + nullopt /* release_version */}); + } + } + + // Verify all the packages have the same version. This is the only + // arrangement we currently (and probably ever) support. The immediate + // problem with supporting different versions (besides the extra + // complexity, of course) is tagging. But since our tags don't include + // revisions, we do allow variations in that. + // + // While at it, notice if we will end up with different revisions which + // we use below when forming the commit message. + // + bool multi_rev (false); + { + const package& f (prj.packages.front ()); + + for (const package& p: prj.packages) + { + const auto& fv (f.current_version); + const auto& pv (p.current_version); + + if (fv.compare (pv, true /* ignore_revision */) != 0) + { + fail << "different current package versions" << + info << "package " << f.name << " version " << fv << + info << "package " << p.name << " version " << pv; + } + + multi_rev = multi_rev || fv.revision != pv.revision; + } + + multi_rev = multi_rev && o.revision (); + } + + // Plan the changes. + // + const char* mode; + if (o.revision ()) {plan_revision (o, prj); mode = "revising";} + else if (o.open ()) {plan_open (o, prj); mode = "opening";} + else if (o.tag ()) {plan_tag (o, prj); mode = "tagging";} + else {plan_version (o, prj); mode = "releasing";} + + const package& pkg (prj.packages.front ()); // Exemplar package. + + bool commit (!o.no_commit () && (pkg.release_version || prj.open_version)); + bool push (o.push ()); + + // Print the plan and ask for confirmation. + // + if (!o.yes ()) + { + diag_record dr (text); + + dr << mode << ":" << '\n'; + + for (const package& p: prj.packages) + { + dr << " package: " << p.name << '\n' + << " current: " << p.current_version << '\n'; + + if (p.release_version) + dr << " release: " << *p.release_version << '\n'; + + if (prj.open_version) + dr << " open: " << *prj.open_version << '\n'; + + // If printing multiple packages, separate them with a blank line. + // + if (prj.packages.size () > 1) + dr << '\n'; + } + + if (!o.tag ()) + dr << " commit: " << (commit ? "yes" : "no") << '\n'; + + dr << " tag: " << (prj.tag ? prj.tag->c_str () : "no") << '\n' + << " push: " << (push ? "yes" : "no"); + + dr.flush (); + + if (!yn_prompt ("continue? [y/n]")) + return 1; + } + + // Stage and commit the project changes. + // + auto commit_project = [&prj] (const string& msg) + { + // We shouldn't have any untracked files or unstaged changes other than + // our modifications, so -a is good enough. + // + run_git (git_ver, + prj.path, + "commit", + verb < 2 ? "-q" : verb > 2 ? "-v" : nullptr, + "-a", + "-m", msg); + }; + + // Release. + // + if (pkg.release_version) + { + // Rewrite each package manifest. + // + for (package& p: prj.packages) + try + { + manifest_name_value& vv (p.version_pos); + + // Rewrite the version. + // + { + manifest_rewriter rw (p.manifest); + vv.value = p.release_version->string (); + rw.replace (vv); + } + + // If we also need to open the next development cycle, update the + // version position for the subsequent manifest rewrite. + // + if (prj.open_version) + vv = parse_manifest (p.manifest); + } + // The IO failure is unlikely to happen (as we have already read the + // manifests) but still possible (write permission is denied, device is + // full, etc.). In this case we may potentially leave the project in an + // inconsistent state as some of the package manifests could have + // already been rewritten. As a result, the subsequent bdep-release may + // fail due to unstaged changes. + // + catch (const io_error& e) + { + fail << "unable to read/write " << p.manifest << ": " << e << + info << "run 'git -C " << prj.path << " checkout -- ./' to revert " + << "any changes and try again"; + } + + // If not committing, then we are done. + // + // In this case it would have been nice to pre-populate the commit + // message but there doesn't seem to be a way to do that. In particular, + // writing to .git/COMMIT_EDITMSG does not work (existing content is + // discarded). + // + if (!commit) + return 0; + + // Commit the manifest rewrites. + // + // If we are releasing multiple revisions, then list every package in + // the form: + // + // Release versions hello/0.1.0+1, libhello/0.1.0+2 + // + string m; + if (multi_rev) + { + for (const package& p: prj.packages) + { + m += m.empty () ? "Release versions " : ", "; + m += p.name.string (); + m += '/'; + m += p.release_version->string (); + } + } + else + m = "Release version " + pkg.release_version->string (); + + commit_project (m); + } + + // Tag. + // + if (prj.tag) + { + // Note that our version can be either current (--tag mode) or release + // (part of the release). + // + const standard_version& cv (pkg.release_version + ? *pkg.release_version + : pkg.current_version); + + // Note that the tag may exist both locally and remotely or only in one + // of the places. The remote case is handled by push. + // + // The git-tag command with -f will replace the existing tag but may + // print to stdout (which we redirect to stderr) something like: + // + // Updated tag 'v0.1.0' (was 8f689ec) + // + run_git (git_ver, + prj.path, + "tag", + prj.replace_tag ? "-f" : nullptr, + "-a", *prj.tag, + "-m", "Tag version " + cv.string ()); + } + + // Open. + // + if (prj.open_version) + { + string ov (prj.open_version->string ()); + + // Rewrite each package manifest (similar code to above). + // + for (package& p: prj.packages) + try + { + manifest_rewriter rw (p.manifest); + p.version_pos.value = ov; + rw.replace (p.version_pos); + } + catch (const io_error& e) + { + // If we are releasing, then the release/revision version have already + // been written to the manifests and the changes have been committed. + // Thus, the user should re-try with the --open option in this case. + // + fail << "unable to read/write " << p.manifest << ": " << e << + info << "run 'git -C " << prj.path << " checkout -- ./' to revert " + << "any changes and try again" << (pkg.release_version + ? " with --open" + : ""); + } + + if (!commit) + return 0; + + // Commit the manifest rewrites. + // + commit_project ("Change version to " + ov); + } + + if (push) + { + // It would have been nice to push commits and tags using just the + // --follow-tags option. However, this doesn't work if we need to + // replace the tag in the remote repository. Thus, we specify the + // repository and refspecs explicitly. + // + string tagspec; + + if (prj.tag) + { + // Force update of the remote tag, if required. + // + if (prj.replace_tag) + tagspec += '+'; + + tagspec += "refs/tags/"; + tagspec += *prj.tag; + } + + // Note that we suppress the (too detailed) push command output if + // the verbosity level is 1. However, we still want to see the + // progress in this case. + // + run_git (git_ver, + prj.path, + "push", + verb < 2 ? "-q" : verb > 3 ? "-v" : nullptr, + verb == 1 ? "--progress" : nullptr, + "origin", + "HEAD", + !tagspec.empty () ? tagspec.c_str () : nullptr); + } + + return 0; + } +} diff --git a/bdep/release.hxx b/bdep/release.hxx new file mode 100644 index 0000000..305f8c2 --- /dev/null +++ b/bdep/release.hxx @@ -0,0 +1,19 @@ +// file : bdep/release.hxx -*- C++ -*- +// copyright : Copyright (c) 2014-2018 Code Synthesis Ltd +// license : MIT; see accompanying LICENSE file + +#ifndef BDEP_RELEASE_HXX +#define BDEP_RELEASE_HXX + +#include +#include + +#include + +namespace bdep +{ + int + cmd_release (const cmd_release_options&, cli::scanner& args); +} + +#endif // BDEP_RELEASE_HXX diff --git a/bdep/sync.cxx b/bdep/sync.cxx index b9a83a0..dfc9f9c 100644 --- a/bdep/sync.cxx +++ b/bdep/sync.cxx @@ -115,16 +115,19 @@ namespace bdep // Project to be synchronized. // - struct project + namespace { - dir_path path; - shared_ptr config; + struct project + { + dir_path path; + shared_ptr config; - bool implicit; - bool fetch; - }; + bool implicit; + bool fetch; + }; - using projects = small_vector; + using projects = small_vector; + } // Append the list of additional (to origin, if not empty) projects that are // using this configuration. diff --git a/bdep/utility.hxx b/bdep/utility.hxx index 57764fa..1f45ded 100644 --- a/bdep/utility.hxx +++ b/bdep/utility.hxx @@ -19,6 +19,9 @@ #include #include +#include // manifest_parser::filter_function +#include // manifest_serializer::filter_function + #include #include #include @@ -208,25 +211,33 @@ namespace bdep T parse_manifest (const path&, const char* what, - bool ignore_unknown = false); + bool ignore_unknown = false, + function = {}); template T parse_manifest (istream&, const string& name, const char* what, - bool ignore_unknown = false); + bool ignore_unknown = false, + function = {}); template void - serialize_manifest (const T&, const path&, const char* what); + serialize_manifest ( + const T&, + const path&, + const char* what, + function = {}); template void - serialize_manifest (const T&, - ostream&, - const string& name, - const char* what); + serialize_manifest ( + const T&, + ostream&, + const string& name, + const char* what, + function = {}); // CLI (sub)command parsing helper. // diff --git a/bdep/utility.txx b/bdep/utility.txx index 9926e12..cab2dd8 100644 --- a/bdep/utility.txx +++ b/bdep/utility.txx @@ -4,9 +4,6 @@ #include // cin -#include -#include - #include namespace bdep @@ -236,7 +233,10 @@ namespace bdep // template T - parse_manifest (const path& f, const char* what, bool iu) + parse_manifest (const path& f, + const char* what, + bool iu, + function ff) { using namespace butl; @@ -249,7 +249,7 @@ namespace bdep fail << what << " manifest file " << f << " does not exist"; ifdstream ifs (f); - return parse_manifest (ifs, f.string (), what, iu); + return parse_manifest (ifs, f.string (), what, iu, move (ff)); } catch (const system_error& e) // EACCES, etc. { @@ -260,13 +260,17 @@ namespace bdep template T - parse_manifest (istream& is, const string& name, const char* what, bool iu) + parse_manifest (istream& is, + const string& name, + const char* what, + bool iu, + function ff) { using namespace butl; try { - manifest_parser p (is, name); + manifest_parser p (is, name, move (ff)); return T (p, iu); } catch (const manifest_parsing& e) @@ -283,7 +287,10 @@ namespace bdep template void - serialize_manifest (const T& m, const path& f, const char* what) + serialize_manifest (const T& m, + const path& f, + const char* what, + function ff) { using namespace std; using namespace butl; @@ -293,7 +300,7 @@ namespace bdep ofdstream ofs (f, ios::binary); auto_rmfile arm (f); // Try to remove on failure ignoring errors. - serialize_manifest (m, ofs, f.string (), what); + serialize_manifest (m, ofs, f.string (), what, move (ff)); ofs.close (); arm.cancel (); @@ -309,13 +316,14 @@ namespace bdep serialize_manifest (const T& m, ostream& os, const string& name, - const char* what) + const char* what, + function ff) { using namespace butl; try { - manifest_serializer s (os, name); + manifest_serializer s (os, name, move (ff)); m.serialize (s); return; } diff --git a/doc/cli.sh b/doc/cli.sh index afce007..534a57e 100755 --- a/doc/cli.sh +++ b/doc/cli.sh @@ -62,8 +62,8 @@ o="--suppress-undocumented --output-prefix bdep- --class-doc bdep::common_option compile "common" $o --output-suffix "-options" --class-doc bdep::common_options=long compile "bdep" $o --output-prefix "" --class-doc bdep::commands=short --class-doc bdep::topics=short -pages="new help init sync fetch status ci publish deinit config test update \ -clean projects-configs" +pages="new help init sync fetch status ci release publish deinit config test \ +update clean projects-configs" for p in $pages; do compile $p $o diff --git a/tests/common.testscript b/tests/common.testscript index 400ae48..5397fe1 100644 --- a/tests/common.testscript +++ b/tests/common.testscript @@ -53,6 +53,7 @@ sync = $* sync update = $* update config = $* config publish = $* publish +release = $* release # All testscripts are named after bdep commands, for example sync.testscript. # So the testscript scope id is a name of the command being tested. diff --git a/tests/release.testscript b/tests/release.testscript new file mode 100644 index 0000000..14d315b --- /dev/null +++ b/tests/release.testscript @@ -0,0 +1,634 @@ +# file : tests/release.testscript +# copyright : Copyright (c) 2014-2018 Code Synthesis Ltd +# license : MIT; see accompanying LICENSE file + +.include common.testscript project.testscript + +# bdep-release requirements for the minimum supported git version are higher +# then the default 2.1.0 (see bdep/release.cxx for details). +# ++if! ($git_version_major > 2 || \ + $git_version_major == 2 && $git_version_minor >= 11) + exit +end + +g = git 2>! >&2 + +# Create the remote repository. +# ++mkdir --no-cleanup prj.git ++$g -C prj.git init --bare &prj.git/*** + +clone_rep = cp --no-cleanup -p -r ../prj.git ./ &prj.git/*** +clone_root_rep = cp --no-cleanup -p -r $~/prj.git ./ &prj.git/*** + +# Prepare the local repository. +# +# Set the origin repository location as a relative path, so we can copy the +# local/remote repository pair into the testscript scopes. +# +gp = $g -C prj + ++$gp config user.name 'Test Script' ++$gp config user.email 'testscript@example.com' ++$gp remote add origin ../prj.git ++$gp add '*' ++$gp commit -m 'Create' ++$gp push --set-upstream origin master + +clone_prj = cp --no-cleanup -p -r ../prj ./ &prj/*** +clone_root_prj = cp --no-cleanup -p -r $~/prj ./ &prj/*** + +clone_repos = $clone_prj && $clone_rep +clone_root_repos = $clone_root_prj && $clone_root_rep + +release += 2>! + +# As a common approach we will be using a second local repository to validate +# the result of the release operation(s), that is normally pushed to the +# remote repository. +# +gp2 = $g -C prj2 +clone2 = $g clone prj.git prj2 &prj2/*** +pull2 = $gp2 pull +log2 = $gp2 log '--pretty=format:"%d %s"' + +: single-pkg +: +{ + test.arguments += --yes -q + + : release + : + { + test.arguments += --push + + : patch + : + { + $clone_root_repos; + + $*; + + $clone2; + $log2 >>:~%EOO%; + % \(HEAD -> master, \.*\) Change version to 0.2.0-a.0.z%d + (tag: v0.1.0) Release version 0.1.0 + Create + EOO + + cat prj2/manifest >>~%EOO% + %.* + name: prj + version: 0.2.0-a.0.z + summary: prj executable + %.* + EOO + } + + : alpha + : + { + $clone_root_repos; + + $* --alpha; + + $clone2; + $log2 >>:~%EOO% + % \(HEAD -> master, \.*\) Change version to 0.1.0-a.1.z%d + (tag: v0.1.0-a.1) Release version 0.1.0-a.1 + Create + EOO + } + + : beta + : + { + $clone_root_repos; + + $* --beta; + + $clone2; + $log2 >>:~%EOO% + % \(HEAD -> master, \.*\) Change version to 0.1.0-b.1.z%d + (tag: v0.1.0-b.1) Release version 0.1.0-b.1 + Create + EOO + } + + : minor + : + { + $clone_root_repos; + + $* --minor; + + $clone2; + $log2 >>:~%EOO% + % \(HEAD -> master, \.*\) Change version to 0.3.0-a.0.z%d + (tag: v0.2.0) Release version 0.2.0 + Create + EOO + } + + : major + : + { + $clone_root_repos; + + $* --major; + + $clone2; + $log2 >>:~%EOO% + % \(HEAD -> master, \.*\) Change version to 1.1.0-a.0.z%d + (tag: v1.0.0) Release version 1.0.0 + Create + EOO + } + + : open + : + { + : beta + : + { + $clone_root_repos; + + $* --open-beta 2>'error: --open-beta specified for final current version 0.1.0' != 0; + + $* --alpha --open-beta; + + $clone2; + $log2 >>:~%EOO%; + % \(HEAD -> master, \.*\) Change version to 0.1.0-b.0.z%d + (tag: v0.1.0-a.1) Release version 0.1.0-a.1 + Create + EOO + + $* --beta --open-beta; + + $pull2; + $log2 >>:~%EOO% + % \(HEAD -> master, \.*\) Change version to 0.1.0-b.1.z%d + (tag: v0.1.0-b.1) Release version 0.1.0-b.1 + Change version to 0.1.0-b.0.z + (tag: v0.1.0-a.1) Release version 0.1.0-a.1 + Create + EOO + } + + : patch + : + { + $clone_root_repos; + + $* --open-patch; + + $clone2; + $log2 >>:~%EOO%; + % \(HEAD -> master, \.*\) Change version to 0.1.1-a.0.z%d + (tag: v0.1.0) Release version 0.1.0 + Create + EOO + + $*; # --open-patch is implied for bugfix release series. + + $pull2; + $log2 >>:~%EOO% + % \(HEAD -> master, \.*\) Change version to 0.1.2-a.0.z%d + (tag: v0.1.1) Release version 0.1.1 + % Change version to 0.1.1-a.0.z% + (tag: v0.1.0) Release version 0.1.0 + Create + EOO + } + + : minor + : + { + $clone_root_repos; + + $* --alpha --open-minor 2>'error: --open-minor specified for alpha current version 0.1.0-a.1' != 0; + $* --beta --open-minor 2>'error: --open-minor specified for beta current version 0.1.0-b.1' != 0; + + $* --open-patch; + $*; + $* --open-minor; + + $clone2; + $log2 >>:~%EOO% + % \(HEAD -> master, \.*\) Change version to 0.2.0-a.0.z%d + (tag: v0.1.2) Release version 0.1.2 + Change version to 0.1.2-a.0.z + (tag: v0.1.1) Release version 0.1.1 + Change version to 0.1.1-a.0.z + (tag: v0.1.0) Release version 0.1.0 + Create + EOO + } + + : major + : + { + $clone_root_repos; + + $* --open-major; + + $clone2; + $log2 >>:~%EOO% + % \(HEAD -> master, \.*\) Change version to 1.0.0-a.0.z%d + (tag: v0.1.0) Release version 0.1.0 + Create + EOO + } + } + + : no-open + : + { + $clone_root_repos; + + $* --no-open; + + $clone2; + $log2 >>:~%EOO%; + % \(HEAD -> master, tag: v0.1.0, \.*\) Release version 0.1.0%d + Create + EOO + + $* --open; + + $pull2; + $log2 >>:~%EOO% + % \(HEAD -> master, \.*\) Change version to 0.2.0-a.0.z%d + (tag: v0.1.0) Release version 0.1.0 + Create + EOO + } + + : no-tag + : + { + $clone_root_repos; + + $* --no-tag; + + $clone2; + $log2 >>:~%EOO%; + % \(HEAD -> master, \.*\) Change version to 0.2.0-a.0.z%d + Release version 0.1.0 + Create + EOO + + $* --tag 2>'error: current version 0.2.0-a.0.z is a snapshot' != 0 + } + + : no-tag-no-open + : + { + $clone_root_repos; + + $* --no-tag --no-open; + + $clone2; + $log2 >>:~%EOO%; + % \(HEAD -> master, \.*\) Release version 0.1.0%d + Create + EOO + + $* --tag; + + $pull2; + $log2 >>:~%EOO%; + % \(HEAD -> master, tag: v0.1.0, \.*\) Release version 0.1.0%d + Create + EOO + + $* --open; + + $pull2; + $log2 >>:~%EOO% + % \(HEAD -> master, \.*\) Change version to 0.2.0-a.0.z%d + (tag: v0.1.0) Release version 0.1.0 + Create + EOO + } + + : no-commit + : + { + $clone_root_repos; + + $* --no-commit 2>'error: both --push and --no-commit specified' != 0 + } + + : validate-manifest + { + : file-value + : + { + $clone_root_repos; + + echo 'description-file: README' >+ prj/manifest; + $gp commit -a -m 'Add description'; + + $* 2>>~%EOE% != 0; + %error: unable to read .+README referenced by description-file manifest value in .+manifest% + EOE + + touch prj/README; + $gp add README; + $gp commit -m 'Add README file'; + + $* 2>>~%EOE% != 0; + %error: description-file manifest value in .+manifest references empty file .+README% + EOE + + echo '.' >= prj/README; + $gp commit -a -m 'Fill README file'; + + $* + } + + : unknown-value + : + { + $clone_root_repos; + + echo 'some-file: README' >+ prj/manifest; + $gp commit -a -m 'Add file'; + + $* 2>>~%EOE% != 0 + %error: invalid package manifest: .+manifest:.+:1: unknown name 'some-file' in package manifest% + EOE + } + } + } + + : revision + : + { + +$clone_root_repos + +$* --no-open --push + + +echo '' >+ prj/manifest + +$gp add manifest + + test.arguments += --revision --push + + : default + : + { + $clone_repos; + + $* 2>>~%EOE%; + %Updated tag 'v0.1.0' \(was \.*\)%d + EOE + + $clone2; + $log2 >>:~%EOO% + % \(HEAD -> master, tag: v0.1.0, \.*\) Release version 0.1.0\+1%d + Release version 0.1.0 + Create + EOO + } + + : no-tag + : + { + $clone_repos; + + $* --no-tag; + + $clone2; + $log2 >>:~%EOO%; + % \(HEAD -> master, \.*\) Release version 0.1.0\+1%d + (tag: v0.1.0) Release version 0.1.0 + Create + EOO + + $release --tag --push --yes -d prj; + + $pull2 --tags; # Updates the existing local tag. + $log2 >>:~%EOO% + % \(HEAD -> master, tag: v0.1.0, \.*\) Release version 0.1.0\+1%d + Release version 0.1.0 + Create + EOO + } + } + + : no-commit + : + { + $clone_root_repos; + + $* --no-commit; + + $clone2; + $log2 >>:~%EOO%; + % \(HEAD -> master, .*\) Create% + EOO + + $gp commit -a -m 'Release version'; + + $* --tag; + $* --open --no-commit; + + $gp commit -a -m 'Change version'; + $gp push origin HEAD --tags; + + $pull2; + $log2 >>:~%EOO% + % \(HEAD -> master, .*\) Change version% + (tag: v0.1.0) Release version + Create + EOO + } +} + +: multi-pkg +: +{ + # Create the remote repository. + # + +mkdir --no-cleanup prj.git + +git -C prj.git init --bare 2>! >&2 &prj.git/*** + + # Create the local repository. + # + new += 2>- + +$new -t empty prj &prj/*** + +$new -t exe --package prj -d prj + +$new -t lib --package libprj -d prj + + +$gp config user.name 'Test Script' + +$gp config user.email 'testscript@example.com' + +$gp remote add origin ../prj.git + +$gp add '*' + +$gp commit -m 'Create' + +$gp push --set-upstream origin master + + test.arguments += --push -q + + : patch + : + { + $clone_repos; + + $* --yes; + + $clone2; + $log2 >>:~%EOO%; + % \(HEAD -> master, \.*\) Change version to 0.2.0-a.0.z%d + (tag: v0.1.0) Release version 0.1.0 + Create + EOO + + cat prj2/prj/manifest >>~%EOO%; + %.* + name: prj + version: 0.2.0-a.0.z + summary: prj executable + %.* + EOO + + cat prj2/libprj/manifest >>~%EOO% + %.* + name: libprj + version: 0.2.0-a.0.z + project: prj + %.* + EOO + } + + : multiple-revisions + : + { + test.arguments += --yes + + $clone_repos; + $* --no-open; + + echo '' >+ prj/prj/manifest; + $gp add prj/manifest; + + $release --revision -q --yes -d prj/prj; + + echo '' >+ prj/prj/manifest; + $gp add prj/manifest; + + echo '' >+ prj/libprj/manifest; + $gp add libprj/manifest; + + $* --revision 2>>~%EOE%; + %Updated tag 'v0.1.0' \(was \.*\)%d + EOE + + $clone2; + $log2 >>:~%EOO% + % \(HEAD -> master, tag: v0.1.0, \.*\) Release versions prj/0.1.0\+2, libprj/0.1.0\+1%d + Release version 0.1.0+1 + Release version 0.1.0 + Create + EOO + } + + : prompt + : + { + +$clone_repos + sp=' ' + + : default + : + { + $clone_repos; + + $* <'y' 2>>:"EOE"; + releasing: + package: prj + current: 0.1.0-a.0.z + release: 0.1.0 + open: 0.2.0-a.0.z + + package: libprj + current: 0.1.0-a.0.z + release: 0.1.0 + open: 0.2.0-a.0.z + + commit: yes + tag: v0.1.0 + push: yes + continue? [y/n]$sp + EOE + + $clone2; + $log2 >>:~%EOO% + % \(HEAD -> master, \.*\) Change version to 0.2.0-a.0.z%d + (tag: v0.1.0) Release version 0.1.0 + Create + EOO + } + + : no-tag + : + { + $clone_repos; + + $* <'y' --no-tag 2>>:"EOE" + releasing: + package: prj + current: 0.1.0-a.0.z + release: 0.1.0 + open: 0.2.0-a.0.z + + package: libprj + current: 0.1.0-a.0.z + release: 0.1.0 + open: 0.2.0-a.0.z + + commit: yes + tag: no + push: yes + continue? [y/n]$sp + EOE + } + + : no-open + : + { + $clone_repos; + + $* <'y' --no-open 2>>:"EOE" + releasing: + package: prj + current: 0.1.0-a.0.z + release: 0.1.0 + + package: libprj + current: 0.1.0-a.0.z + release: 0.1.0 + + commit: yes + tag: v0.1.0 + push: yes + continue? [y/n]$sp + EOE + } + } +} + +: options-incompatibility +: +{ + $clone_prj; + + $* --revision --open 2>'error: both --revision and --open specified' != 0; + $* --revision --alpha 2>'error: both --revision and --alpha specified' != 0; + $* --revision --no-open 2>'error: both --revision and --no-open specified' != 0; + $* --revision --open-beta 2>'error: both --revision and --open-beta specified' != 0; + $* --open --no-tag 2>'error: both --open and --no-tag specified' != 0; + $* --tag --no-commit 2>'error: both --tag and --no-commit specified' != 0; + $* --push --no-commit 2>'error: both --push and --no-commit specified' != 0 +} -- cgit v1.1