use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::fmt::Write as _;
use std::fs;
use std::future::Future;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command as ProcessCommand;
use std::time::Duration;
use std::time::Instant;
use chrono::Utc;
#[cfg(feature = "cargo")]
use monochange_cargo::CargoAdapter;
use monochange_cargo::discover_cargo_packages;
use monochange_config::apply_version_groups;
use monochange_config::build_changeset_load_context;
use monochange_config::load_change_signals;
use monochange_config::load_changeset_contents_with_context;
use monochange_config::load_workspace_configuration;
use monochange_core::BumpSeverity;
use monochange_core::CliCommandDefinition;
use monochange_core::DiscoveryReport;
use monochange_core::Ecosystem;
use monochange_core::EcosystemRegistry;
use monochange_core::LockfileCommandDefinition;
use monochange_core::LockfileCommandExecution;
use monochange_core::MonochangeError;
use monochange_core::MonochangeResult;
use monochange_core::PackageRecord;
use monochange_core::PackageType;
use monochange_core::PlannedVersionGroup;
use monochange_core::PrereleaseBase;
use monochange_core::PrereleaseConfiguration;
use monochange_core::PrereleaseNumbering;
use monochange_core::ReleaseDecision;
use monochange_core::ReleasePlan;
use monochange_core::SourceConfiguration;
use monochange_core::default_cli_commands;
#[cfg(feature = "dart")]
use monochange_dart::DartAdapter;
use monochange_deno::DenoAdapter;
use monochange_go::GoAdapter;
#[cfg(feature = "npm")]
use monochange_npm::NpmAdapter;
use monochange_python::PythonAdapter;
use semver::Prerelease;
use semver::Version;
use serde::Deserialize;
use serde::Serialize;
use serde_json::json;
use tokio::task::JoinHandle;
use tokio::time::timeout;
use typed_builder::TypedBuilder;
use crate::interactive;
use crate::*;
pub(crate) struct InitWorkspaceResult {
pub config_path: PathBuf,
pub workflow_paths: Vec<PathBuf>,
}
impl InitWorkspaceResult {
pub fn summary(&self) -> String {
let mut lines = vec![format!("wrote {}", self.config_path.display())];
for path in &self.workflow_paths {
lines.push(format!("wrote {}", path.display()));
}
lines.join("\n")
}
}
const PRERELEASE_STATE_PATH: &str = ".monochange/prerelease-state.json";
const PRERELEASE_STATE_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct PrereleaseState {
schema_version: u32,
channel: String,
numbering: PrereleaseNumbering,
base: PrereleaseBase,
created_at: String,
updated_at: String,
#[serde(default)]
packages: BTreeMap<String, PrereleaseStateEntry>,
#[serde(default)]
groups: BTreeMap<String, PrereleaseStateEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct PrereleaseStateEntry {
#[serde(rename = "original_stable_version")]
original_stable: Version,
#[serde(rename = "planned_stable_version")]
planned_stable: Version,
#[serde(rename = "latest_prerelease_version")]
latest_prerelease: Version,
}
#[derive(Debug, Clone)]
struct PrereleasePreparedState {
state_update: FileUpdate,
}
fn prerelease_state_path(root: &Path) -> PathBuf {
root.join(PRERELEASE_STATE_PATH)
}
fn load_prerelease_state(root: &Path) -> MonochangeResult<Option<PrereleaseState>> {
let path = prerelease_state_path(root);
if !path.exists() {
return Ok(None);
}
let contents = fs::read_to_string(&path).map_err(|error| {
MonochangeError::Io(format!("failed to read {}: {error}", path.display()))
})?;
let state = serde_json::from_str::<PrereleaseState>(&contents).map_err(|error| {
MonochangeError::Config(format!("failed to parse {}: {error}", path.display()))
})?;
if state.schema_version != PRERELEASE_STATE_SCHEMA_VERSION {
return Err(MonochangeError::Config(format!(
"{} uses unsupported schema_version {}",
path.display(),
state.schema_version
)));
}
Ok(Some(state))
}
fn stable_version(version: &Version) -> Version {
Version::new(version.major, version.minor, version.patch)
}
fn original_package_version(
state: Option<&PrereleaseState>,
package: &PackageRecord,
) -> Option<Version> {
state
.and_then(|state| state.packages.get(&package.id))
.map(|entry| entry.original_stable.clone())
.or_else(|| package.current_version.as_ref().map(stable_version))
}
fn original_group_version(
state: Option<&PrereleaseState>,
group_id: &str,
packages: &[PackageRecord],
) -> Option<Version> {
state
.and_then(|state| state.groups.get(group_id))
.map(|entry| entry.original_stable.clone())
.or_else(|| {
packages
.iter()
.filter(|package| package.version_group_id.as_deref() == Some(group_id))
.filter_map(|package| package.current_version.as_ref().map(stable_version))
.max()
})
}
fn discovery_with_prerelease_baselines(
discovery: &DiscoveryReport,
state: Option<&PrereleaseState>,
configuration: &PrereleaseConfiguration,
) -> DiscoveryReport {
let mut adjusted = discovery.clone();
for package in &mut adjusted.packages {
let baseline = match configuration.base {
PrereleaseBase::Fixed => configuration.base_version.clone(),
PrereleaseBase::Planned | PrereleaseBase::CurrentStable => {
original_package_version(state, package)
}
};
if let Some(version) = baseline {
package.current_version = Some(version);
}
}
adjusted
}
fn prerelease_identifier(
config: &PrereleaseConfiguration,
stable_base: &Version,
latest: Option<&Version>,
) -> String {
let channel = config.channel.to_ascii_lowercase();
match config.numbering {
PrereleaseNumbering::Increment => {
let next = latest
.and_then(|version| {
if stable_version(version) == *stable_base {
version.pre.as_str().rsplit_once('.')?.1.parse::<u64>().ok()
} else {
None
}
})
.map_or(0, |value| value + 1);
format!("{channel}.{next}")
}
PrereleaseNumbering::Date => format!("{channel}.{}", Utc::now().format("%Y%m%d")),
PrereleaseNumbering::Datetime => format!("{channel}.{}", Utc::now().format("%Y%m%d%H%M")),
}
}
fn append_prerelease(
base: &Version,
config: &PrereleaseConfiguration,
latest: Option<&Version>,
) -> MonochangeResult<Version> {
let mut version = base.clone();
version.pre =
Prerelease::new(&prerelease_identifier(config, base, latest)).map_err(|error| {
MonochangeError::Config(format!(
"failed to build prerelease version for `{base}`: {error}"
))
})?;
Ok(version)
}
fn apply_prerelease_versions_to_plan(
plan: &mut ReleasePlan,
discovery: &DiscoveryReport,
state: Option<&PrereleaseState>,
config: &PrereleaseConfiguration,
) -> MonochangeResult<()> {
let package_by_id = discovery
.packages
.iter()
.map(|package| (package.id.as_str(), package))
.collect::<BTreeMap<_, _>>();
let mut planned_group_versions = BTreeMap::new();
for group in &mut plan.groups {
let Some(planned) = group.planned_version.clone() else {
continue;
};
let base = match config.base {
PrereleaseBase::Planned => planned,
PrereleaseBase::CurrentStable => {
original_group_version(state, &group.group_id, &discovery.packages)
.unwrap_or(planned)
}
PrereleaseBase::Fixed => config.base_version.clone().unwrap_or(planned),
};
let latest = state
.and_then(|state| state.groups.get(&group.group_id))
.map(|entry| &entry.latest_prerelease);
let prerelease = append_prerelease(&base, config, latest)?;
planned_group_versions.insert(group.group_id.clone(), prerelease.clone());
group.planned_version = Some(prerelease);
}
for decision in &mut plan.decisions {
let Some(planned) = decision.planned_version.clone() else {
continue;
};
if let Some(group_id) = decision.group_id.as_ref()
&& let Some(group_version) = planned_group_versions.get(group_id)
{
decision.planned_version = Some(group_version.clone());
continue;
}
let package = package_by_id.get(decision.package_id.as_str()).copied();
let base = match config.base {
PrereleaseBase::Planned => planned,
PrereleaseBase::CurrentStable => {
package
.and_then(|package| original_package_version(state, package))
.unwrap_or(planned)
}
PrereleaseBase::Fixed => config.base_version.clone().unwrap_or(planned),
};
let latest = state
.and_then(|state| state.packages.get(&decision.package_id))
.map(|entry| &entry.latest_prerelease);
decision.planned_version = Some(append_prerelease(&base, config, latest)?);
}
Ok(())
}
fn synthesize_no_changeset_prerelease_plan(discovery: &DiscoveryReport) -> ReleasePlan {
let group_by_package = discovery
.version_groups
.iter()
.flat_map(|group| {
group
.members
.iter()
.cloned()
.map(move |member| (member, group.group_id.clone()))
})
.collect::<BTreeMap<_, _>>();
let groups = discovery
.version_groups
.iter()
.filter_map(|group| {
let planned_version = group
.members
.iter()
.filter_map(|member| {
discovery
.packages
.iter()
.find(|package| package.id == *member)
})
.find_map(|package| package.current_version.clone())?;
Some(PlannedVersionGroup {
group_id: group.group_id.clone(),
display_name: group.display_name.clone(),
members: group.members.clone(),
mismatch_detected: group.mismatch_detected,
planned_version: Some(planned_version),
recommended_bump: BumpSeverity::Patch,
})
})
.collect::<Vec<_>>();
let decisions = discovery
.packages
.iter()
.filter_map(|package| {
let planned_version = package.current_version.clone()?;
Some(ReleaseDecision {
package_id: package.id.clone(),
trigger_type: "prerelease".to_string(),
recommended_bump: BumpSeverity::Patch,
planned_version: Some(planned_version),
group_id: group_by_package.get(&package.id).cloned(),
reasons: vec!["prerelease mode is enabled without changesets".to_string()],
upstream_sources: Vec::new(),
warnings: Vec::new(),
})
})
.collect::<Vec<_>>();
ReleasePlan {
workspace_root: discovery.workspace_root.clone(),
decisions,
groups,
warnings: Vec::new(),
unresolved_items: Vec::new(),
compatibility_evidence: Vec::new(),
}
}
fn build_prerelease_state_update(
root: &Path,
plan: &ReleasePlan,
discovery: &DiscoveryReport,
previous: Option<&PrereleaseState>,
config: &PrereleaseConfiguration,
) -> MonochangeResult<PrereleasePreparedState> {
let now = Utc::now().to_rfc3339();
let mut state = PrereleaseState {
schema_version: PRERELEASE_STATE_SCHEMA_VERSION,
channel: config.channel.to_ascii_lowercase(),
numbering: config.numbering,
base: config.base,
created_at: previous.map_or_else(|| now.clone(), |state| state.created_at.clone()),
updated_at: now,
packages: BTreeMap::new(),
groups: BTreeMap::new(),
};
let package_by_id = discovery
.packages
.iter()
.map(|package| (package.id.as_str(), package))
.collect::<BTreeMap<_, _>>();
for decision in &plan.decisions {
let Some(latest) = decision.planned_version.clone() else {
continue;
};
let Some(package) = package_by_id.get(decision.package_id.as_str()).copied() else {
continue;
};
state.packages.insert(
decision.package_id.clone(),
PrereleaseStateEntry {
original_stable: original_package_version(previous, package)
.unwrap_or_else(|| stable_version(&latest)),
planned_stable: stable_version(&latest),
latest_prerelease: latest,
},
);
}
for group in &plan.groups {
let Some(latest) = group.planned_version.clone() else {
continue;
};
state.groups.insert(
group.group_id.clone(),
PrereleaseStateEntry {
original_stable: original_group_version(
previous,
&group.group_id,
&discovery.packages,
)
.unwrap_or_else(|| stable_version(&latest)),
planned_stable: stable_version(&latest),
latest_prerelease: latest,
},
);
}
let content = serde_json::to_vec_pretty(&state).map_err(|error| {
MonochangeError::Config(format!("failed to serialize prerelease state: {error}"))
})?;
Ok(PrereleasePreparedState {
state_update: FileUpdate {
path: prerelease_state_path(root),
content,
},
})
}
#[must_use = "the initialization result must be checked"]
pub(crate) fn init_workspace(
root: &Path,
force: bool,
provider: Option<&str>,
) -> MonochangeResult<InitWorkspaceResult> {
let path = monochange_config::config_path(root);
if path.exists() && !force {
return Err(MonochangeError::Config(format!(
"{} already exists; rerun with --force to overwrite it",
path.display()
)));
}
let remote = provider.and_then(|_| detect_remote_owner_repo(root));
let content = render_annotated_init_config(root, provider, remote.as_ref())?;
fs::write(&path, &content).map_err(|error| {
MonochangeError::Io(format!("failed to write {}: {error}", path.display()))
})?;
let workflow_paths = if provider == Some("github") {
write_github_workflows(root)?
} else {
Vec::new()
};
Ok(InitWorkspaceResult {
config_path: path,
workflow_paths,
})
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub(crate) struct RemoteInfo {
pub owner: String,
pub repo: String,
}
pub(crate) fn detect_remote_owner_repo(root: &Path) -> Option<RemoteInfo> {
let output = ProcessCommand::new("git")
.current_dir(root)
.args(["remote", "get-url", "origin"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let url = String::from_utf8(output.stdout).ok()?.trim().to_string();
parse_remote_url(&url)
}
pub(crate) fn parse_remote_url(url: &str) -> Option<RemoteInfo> {
let owner_repo = if let Some(rest) = url.strip_prefix("git@") {
rest.split_once(':').map(|(_, path)| path.to_string())
} else if url.starts_with("https://") || url.starts_with("http://") {
url.split_once("//")
.and_then(|(_, rest)| rest.split_once('/'))
.map(|(_, path)| path.to_string())
} else if url.starts_with("ssh://") {
url.strip_prefix("ssh://")
.and_then(|rest| rest.split_once('/'))
.map(|(_, path)| path.to_string())
} else {
None
}?;
let owner_repo = owner_repo.strip_suffix(".git").unwrap_or(&owner_repo);
let (owner, repo) = owner_repo.split_once('/')?;
if owner.is_empty() || repo.is_empty() || repo.contains('/') {
return None;
}
Some(RemoteInfo {
owner: owner.to_string(),
repo: repo.to_string(),
})
}
const CHANGESET_POLICY_WORKFLOW: &str = include_str!("templates/changeset-policy.yml");
const RELEASE_WORKFLOW: &str = include_str!("templates/release.yml");
fn write_github_workflows(root: &Path) -> MonochangeResult<Vec<PathBuf>> {
let workflows_dir = root.join(".github/workflows");
fs::create_dir_all(&workflows_dir).map_err(|error| {
MonochangeError::Io(format!(
"failed to create {}: {error}",
workflows_dir.display()
))
})?;
let mut paths = Vec::new();
for (name, content) in [
("changeset-policy.yml", CHANGESET_POLICY_WORKFLOW),
("release.yml", RELEASE_WORKFLOW),
] {
let path = workflows_dir.join(name);
fs::write(&path, content).map_err(|error| {
MonochangeError::Io(format!("failed to write {}: {error}", path.display()))
})?;
paths.push(path);
}
Ok(paths)
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub(crate) struct PopulateWorkspaceResult {
pub path: PathBuf,
pub added_commands: Vec<String>,
}
#[must_use = "the population result must be checked"]
pub(crate) fn populate_workspace(root: &Path) -> MonochangeResult<PopulateWorkspaceResult> {
let path = monochange_config::config_path(root);
if !path.exists() {
return Err(MonochangeError::Config(format!(
"{} does not exist; run `mc init` first or create a monochange.toml before running `mc populate`",
path.display()
)));
}
let contents = match fs::read_to_string(&path) {
Ok(contents) => contents,
Err(error) => {
return Err(MonochangeError::Io(format!(
"failed to read {}: {error}",
path.display()
)));
}
};
let existing = existing_cli_command_names(&contents, &path)?;
let missing = default_cli_commands()
.into_iter()
.filter(|command| !existing.contains(&command.name))
.collect::<Vec<_>>();
if missing.is_empty() {
return Ok(PopulateWorkspaceResult {
path,
added_commands: Vec::new(),
});
}
let mut updated = contents.trim_end().to_string();
if !updated.is_empty() {
updated.push_str("\n\n");
}
updated.push_str(&render_cli_commands_toml(&missing));
updated.push('\n');
if let Err(error) = fs::write(&path, updated) {
return Err(MonochangeError::Io(format!(
"failed to write {}: {error}",
path.display()
)));
}
Ok(PopulateWorkspaceResult {
path,
added_commands: missing.into_iter().map(|command| command.name).collect(),
})
}
fn existing_cli_command_names(contents: &str, path: &Path) -> MonochangeResult<BTreeSet<String>> {
if contents.trim().is_empty() {
return Ok(BTreeSet::new());
}
let document = toml::from_str::<toml::Value>(contents).map_err(|error| {
MonochangeError::Config(format!("failed to parse {}: {error}", path.display()))
})?;
Ok(document
.get("cli")
.and_then(toml::Value::as_table)
.map(|table| table.keys().cloned().collect())
.unwrap_or_default())
}
pub(crate) fn render_cli_commands_toml(commands: &[CliCommandDefinition]) -> String {
let mut rendered = String::new();
for (index, command) in commands.iter().enumerate() {
if index > 0 {
rendered.push_str("\n\n");
}
render_cli_command_toml(&mut rendered, command);
}
rendered
}
fn render_cli_command_toml(rendered: &mut String, command: &CliCommandDefinition) {
writeln!(rendered, "[cli.{}]", command.name)
.unwrap_or_else(|error| panic!("writing to String cannot fail: {error}"));
if let Some(help_text) = &command.help_text {
write_toml_key_value(rendered, "help_text", &render_toml_string(help_text));
}
for input in &command.inputs {
rendered.push('\n');
writeln!(rendered, "[[cli.{}.inputs]]", command.name)
.unwrap_or_else(|error| panic!("writing to String cannot fail: {error}"));
render_cli_input_toml(rendered, input);
}
for step in &command.steps {
rendered.push('\n');
writeln!(rendered, "[[cli.{}.steps]]", command.name)
.unwrap_or_else(|error| panic!("writing to String cannot fail: {error}"));
render_cli_step_toml(rendered, step);
}
}
fn write_toml_key_value(rendered: &mut String, key: &str, value: &str) {
writeln!(rendered, "{key} = {value}")
.unwrap_or_else(|error| panic!("writing to String cannot fail: {error}"));
}
fn render_cli_input_toml(rendered: &mut String, input: &monochange_core::CliInputDefinition) {
write_toml_key_value(rendered, "name", &render_toml_string(&input.name));
write_toml_key_value(
rendered,
"type",
&render_toml_string(match input.kind {
monochange_core::CliInputKind::String => "string",
monochange_core::CliInputKind::StringList => "string_list",
monochange_core::CliInputKind::Path => "path",
monochange_core::CliInputKind::Choice => "choice",
monochange_core::CliInputKind::Boolean => "boolean",
}),
);
input.help_text.iter().for_each(|help_text| {
write_toml_key_value(rendered, "help_text", &render_toml_string(help_text));
});
if input.required {
write_toml_key_value(rendered, "required", "true");
}
if let Some(default) = &input.default {
write_toml_key_value(rendered, "default", &render_toml_string(default));
}
if !input.choices.is_empty() {
write_toml_key_value(
rendered,
"choices",
&render_toml_array(input.choices.iter()),
);
}
if let Some(short) = input.short {
write_toml_key_value(rendered, "short", &render_toml_string(&short.to_string()));
}
}
fn render_cli_step_toml(rendered: &mut String, step: &CliStepDefinition) {
let step_type = step.kind_name();
writeln!(rendered, "type = {}", render_toml_string(step_type))
.unwrap_or_else(|error| panic!("writing to String cannot fail: {error}"));
if let Some(when) = step.when() {
writeln!(rendered, "when = {}", render_toml_string(when))
.unwrap_or_else(|error| panic!("writing to String cannot fail: {error}"));
}
match step {
CliStepDefinition::Command {
command,
dry_run_command,
shell,
id,
variables,
inputs,
..
} => {
writeln!(rendered, "command = {}", render_toml_string(command))
.unwrap_or_else(|error| panic!("writing to String cannot fail: {error}"));
if let Some(dry_run_command) = dry_run_command {
writeln!(
rendered,
"dry_run_command = {}",
render_toml_string(dry_run_command)
)
.unwrap_or_else(|error| panic!("writing to String cannot fail: {error}"));
}
match shell {
monochange_core::ShellConfig::None => {}
monochange_core::ShellConfig::Default => {
writeln!(rendered, "shell = true")
.unwrap_or_else(|error| panic!("writing to String cannot fail: {error}"));
}
monochange_core::ShellConfig::Custom(shell) => {
writeln!(rendered, "shell = {}", render_toml_string(shell))
.unwrap_or_else(|error| panic!("writing to String cannot fail: {error}"));
}
}
if let Some(id) = id {
write_toml_key_value(rendered, "id", &render_toml_string(id));
}
if let Some(variables) = variables {
writeln!(
rendered,
"variables = {}",
render_command_variables_inline_table(variables)
)
.unwrap_or_else(|error| panic!("writing to String cannot fail: {error}"));
}
render_step_inputs_toml(rendered, inputs);
}
_ => {
render_step_inputs_toml(rendered, step.inputs());
}
}
}
fn render_step_inputs_toml(
rendered: &mut String,
inputs: &BTreeMap<String, monochange_core::CliStepInputValue>,
) {
if inputs.is_empty() {
return;
}
let rendered_inputs = if inputs
.values()
.all(|value| matches!(value, monochange_core::CliStepInputValue::Inherited))
{
render_toml_array(inputs.keys())
} else {
render_step_inputs_inline_table(inputs)
};
writeln!(rendered, "inputs = {rendered_inputs}")
.unwrap_or_else(|error| panic!("writing to String cannot fail: {error}"));
}
fn render_step_inputs_inline_table(
inputs: &BTreeMap<String, monochange_core::CliStepInputValue>,
) -> String {
let mut rendered = String::from("{ ");
let mut first = true;
for (name, value) in inputs {
if first {
first = false;
} else {
rendered.push_str(", ");
}
let _ = write!(
rendered,
"{name} = {}",
render_step_input_value(name, value)
);
}
rendered.push_str(" }");
rendered
}
fn render_step_input_value(name: &str, value: &monochange_core::CliStepInputValue) -> String {
match value {
monochange_core::CliStepInputValue::Inherited => {
render_toml_string(&format!("{{{{ inputs.{name} }}}}"))
}
monochange_core::CliStepInputValue::String(value) => render_toml_string(value),
monochange_core::CliStepInputValue::Boolean(value) => value.to_string(),
monochange_core::CliStepInputValue::List(values) => render_toml_array(values.iter()),
}
}
fn render_command_variables_inline_table(
variables: &BTreeMap<String, monochange_core::CommandVariable>,
) -> String {
let mut rendered = String::from("{ ");
let mut first = true;
for (name, value) in variables {
if first {
first = false;
} else {
rendered.push_str(", ");
}
let value = match value {
monochange_core::CommandVariable::Version => "version",
monochange_core::CommandVariable::GroupVersion => "group_version",
monochange_core::CommandVariable::ReleasedPackages => "released_packages",
monochange_core::CommandVariable::ChangedFiles => "changed_files",
monochange_core::CommandVariable::Changesets => "changesets",
};
let _ = write!(rendered, "{name} = {}", render_toml_string(value));
}
rendered.push_str(" }");
rendered
}
fn render_toml_array<I, S>(values: I) -> String
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut rendered = String::from("[");
write_toml_array_items(&mut rendered, values);
rendered.push(']');
rendered
}
fn write_toml_array_items<I, S>(rendered: &mut String, values: I)
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut first = true;
for value in values {
if first {
first = false;
} else {
rendered.push_str(", ");
}
rendered.push_str(&render_toml_string(value.as_ref()));
}
}
fn render_toml_string(value: &str) -> String {
toml::Value::String(value.to_string()).to_string()
}
const INIT_TEMPLATE: &str = include_str!("monochange.toml.template");
fn render_annotated_init_config(
root: &Path,
provider: Option<&str>,
remote: Option<&RemoteInfo>,
) -> MonochangeResult<String> {
let packages = discover_packages(root)?;
let mut template_packages = Vec::new();
let mut package_ids = Vec::<String>::new();
let mut name_counts = BTreeMap::<String, usize>::new();
for package in &packages {
let count = name_counts.entry(package.name.clone()).or_default();
*count += 1;
let id = if *count == 1 {
package.name.clone()
} else {
format!("{}-{}", package.name, package.ecosystem.as_str())
};
package_ids.push(id.clone());
let manifest_dir = package.manifest_path.parent().unwrap_or(root).to_path_buf();
let relative_dir = root_relative(root, &manifest_dir);
let pkg_type = package_type_for_ecosystem(package.ecosystem);
let changelog = detect_default_changelog(root, &manifest_dir);
let type_str = match pkg_type {
PackageType::Cargo => "cargo",
PackageType::Npm => "npm",
PackageType::Deno => "deno",
PackageType::Dart => "dart",
PackageType::Flutter => "flutter",
PackageType::Python => "python",
PackageType::Go => "go",
_ => unreachable!(),
};
let mut entry = BTreeMap::new();
entry.insert("id", json!(id));
entry.insert("path", json!(relative_dir.display().to_string()));
entry.insert("type", json!(type_str));
if let Some(cl) = changelog {
entry.insert("changelog", json!(cl.display().to_string()));
}
template_packages.push(json!(entry));
}
let has_cargo = packages.iter().any(|p| p.ecosystem == Ecosystem::Cargo);
let has_npm = packages.iter().any(|p| p.ecosystem == Ecosystem::Npm);
let has_deno = packages.iter().any(|p| p.ecosystem == Ecosystem::Deno);
let has_dart = packages
.iter()
.any(|p| p.ecosystem == Ecosystem::Dart || p.ecosystem == Ecosystem::Flutter);
let has_python = packages.iter().any(|p| p.ecosystem == Ecosystem::Python);
let has_go = packages.iter().any(|p| p.ecosystem == Ecosystem::Go);
let mut package_ids_toml = String::new();
write_toml_array_items(&mut package_ids_toml, package_ids.iter());
let context = json!({
"packages": template_packages,
"has_group": package_ids.len() > 1,
"package_ids_toml": package_ids_toml,
"has_cargo": has_cargo,
"has_npm": has_npm,
"has_deno": has_deno,
"has_dart": has_dart,
"has_python": has_python,
"has_go": has_go,
"provider": provider.unwrap_or(""),
"owner": remote.map_or("your-org", |r| r.owner.as_str()),
"repo": remote.map_or("your-repo", |r| r.repo.as_str()),
});
let jinja_context = minijinja::Value::from_serialize(&context);
let rendered = render_jinja_template(INIT_TEMPLATE, &jinja_context)?;
let mut collapsed = String::with_capacity(rendered.len());
let mut consecutive_blanks = 0u32;
for line in rendered.lines() {
if line.trim().is_empty() {
consecutive_blanks += 1;
if consecutive_blanks <= 2 {
collapsed.push('\n');
}
} else {
consecutive_blanks = 0;
collapsed.push_str(line);
collapsed.push('\n');
}
}
Ok(collapsed.trim_start().to_string())
}
fn build_ecosystem_registry() -> EcosystemRegistry {
let mut registry = EcosystemRegistry::new();
#[cfg(feature = "cargo")]
registry.push_adapter(Box::new(CargoAdapter));
#[cfg(feature = "npm")]
registry.push_adapter(Box::new(NpmAdapter));
#[cfg(feature = "deno")]
registry.push_adapter(Box::new(DenoAdapter));
#[cfg(feature = "dart")]
registry.push_adapter(Box::new(DartAdapter));
#[cfg(feature = "python")]
registry.push_adapter(Box::new(PythonAdapter));
#[cfg(feature = "go")]
registry.push_adapter(Box::new(GoAdapter));
registry
}
fn discover_packages(root: &Path) -> MonochangeResult<Vec<PackageRecord>> {
let result = build_ecosystem_registry().discover_all(root)?;
let mut packages = result.packages;
normalize_package_ids(root, &mut packages);
packages.sort_by(|left, right| left.id.cmp(&right.id));
packages.dedup_by(|left, right| left.id == right.id);
Ok(packages)
}
fn normalize_package_ids(root: &Path, packages: &mut [PackageRecord]) {
for package in packages {
let Some(relative_manifest) = relative_to_root(root, &package.manifest_path) else {
continue;
};
package.id = format!(
"{}:{}",
package.ecosystem.as_str(),
relative_manifest.display()
);
}
}
fn detect_default_changelog(root: &Path, manifest_dir: &Path) -> Option<PathBuf> {
let candidates = [
manifest_dir.join("CHANGELOG.md"),
manifest_dir.join("changelog.md"),
];
for candidate in candidates {
if candidate.exists() {
return Some(root_relative(root, &candidate));
}
}
None
}
#[allow(clippy::match_same_arms)]
fn package_type_for_ecosystem(ecosystem: Ecosystem) -> PackageType {
match ecosystem {
Ecosystem::Cargo => PackageType::Cargo,
Ecosystem::Npm => PackageType::Npm,
Ecosystem::Deno => PackageType::Deno,
Ecosystem::Dart => PackageType::Dart,
Ecosystem::Flutter => PackageType::Flutter,
Ecosystem::Python => PackageType::Python,
Ecosystem::Go => PackageType::Go,
_ => PackageType::Cargo,
}
}
#[test]
fn package_type_for_ecosystem_maps_python() {
assert_eq!(
package_type_for_ecosystem(Ecosystem::Python),
PackageType::Python
);
assert_eq!(PackageType::Python.as_str(), "python");
assert_eq!(package_type_for_ecosystem(Ecosystem::Go), PackageType::Go);
assert_eq!(PackageType::Go.as_str(), "go");
}
#[test]
fn render_annotated_init_config_includes_go_package_type() {
let tempdir = tempfile::tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let root = tempdir.path();
fs::write(
root.join("go.mod"),
"module github.com/example/app\n\ngo 1.22\n",
)
.unwrap_or_else(|error| panic!("write go.mod: {error}"));
let rendered = render_annotated_init_config(root, None, None)
.unwrap_or_else(|error| panic!("render init config: {error}"));
assert!(rendered.contains("type = \"go\""));
}
#[test]
fn render_annotated_init_config_includes_python_package_type() {
let tempdir = tempfile::tempdir().unwrap_or_else(|error| panic!("tempdir: {error}"));
let root = tempdir.path();
fs::write(
root.join("pyproject.toml"),
"[project]\nname = \"python-app\"\nversion = \"1.0.0\"\n",
)
.unwrap_or_else(|error| panic!("write pyproject: {error}"));
let rendered = render_annotated_init_config(root, None, None)
.unwrap_or_else(|error| panic!("render init config: {error}"));
assert!(rendered.contains("type = \"python\""), "{rendered}");
}
pub(crate) fn build_lockfile_command_executions(
root: &Path,
configuration: &monochange_core::WorkspaceConfiguration,
packages: &[PackageRecord],
plan: &ReleasePlan,
) -> MonochangeResult<Vec<LockfileCommandExecution>> {
let released_versions = released_versions_by_record_id(plan);
#[cfg(feature = "cargo")]
warn_about_incomplete_cargo_lockfiles(root, configuration, packages, &released_versions);
#[cfg(feature = "cargo")]
let cargo_executions = resolve_lockfile_command_executions(
root,
&configuration.cargo.lockfile_commands,
packages.iter().any(|package| {
package.ecosystem == Ecosystem::Cargo && released_versions.contains_key(&package.id)
}),
)?;
#[cfg(feature = "npm")]
let npm_executions = resolve_lockfile_command_executions(
root,
&configuration.npm.lockfile_commands,
packages.iter().any(|package| {
package.ecosystem == Ecosystem::Npm && released_versions.contains_key(&package.id)
}),
)?;
#[cfg(feature = "deno")]
let deno_executions = resolve_lockfile_command_executions(
root,
&configuration.deno.lockfile_commands,
packages.iter().any(|package| {
package.ecosystem == Ecosystem::Deno && released_versions.contains_key(&package.id)
}),
)?;
#[cfg(feature = "dart")]
let dart_executions = resolve_lockfile_command_executions(
root,
&configuration.dart.lockfile_commands,
packages.iter().any(|package| {
matches!(package.ecosystem, Ecosystem::Dart | Ecosystem::Flutter)
&& released_versions.contains_key(&package.id)
}),
)?;
#[cfg(feature = "python")]
let python_executions = resolve_lockfile_command_executions(
root,
&configuration.python.lockfile_commands,
packages.iter().any(|package| {
package.ecosystem == Ecosystem::Python && released_versions.contains_key(&package.id)
}),
)?;
#[cfg(feature = "go")]
let go_executions = resolve_lockfile_command_executions(
root,
&configuration.go.lockfile_commands,
packages.iter().any(|package| {
package.ecosystem == Ecosystem::Go && released_versions.contains_key(&package.id)
}),
)?;
let mut executions = Vec::new();
#[cfg(feature = "cargo")]
executions.extend(cargo_executions);
#[cfg(feature = "npm")]
executions.extend(npm_executions);
#[cfg(feature = "deno")]
executions.extend(deno_executions);
#[cfg(feature = "dart")]
executions.extend(dart_executions);
#[cfg(feature = "python")]
executions.extend(python_executions);
#[cfg(feature = "go")]
executions.extend(go_executions);
Ok(dedup_lockfile_command_executions(executions))
}
#[cfg(feature = "cargo")]
fn warn_about_incomplete_cargo_lockfiles(
root: &Path,
configuration: &monochange_core::WorkspaceConfiguration,
packages: &[PackageRecord],
released_versions: &BTreeMap<String, String>,
) {
if !configuration.cargo.lockfile_commands.is_empty() {
return;
}
let released_packages = packages
.iter()
.filter(|package| {
package.ecosystem == Ecosystem::Cargo && released_versions.contains_key(&package.id)
})
.collect::<Vec<_>>();
if released_packages.is_empty() {
return;
}
let cargo_packages = packages
.iter()
.filter(|package| package.ecosystem == Ecosystem::Cargo)
.collect::<Vec<_>>();
let mut warned_lockfiles = BTreeSet::new();
for package in released_packages {
for lockfile in monochange_cargo::discover_lockfiles(package) {
let shared_packages = cargo_packages
.iter()
.copied()
.filter(|candidate| {
monochange_cargo::discover_lockfiles(candidate).contains(&lockfile)
})
.collect::<Vec<_>>();
if !monochange_cargo::lockfile_requires_command_refresh(&lockfile, &shared_packages) {
continue;
}
let relative_lockfile = root_relative(root, &lockfile);
if warned_lockfiles.insert(relative_lockfile.clone()) {
eprintln!(
"warning: `{}` still looks incomplete after monochange rewrote it directly; run `cargo generate-lockfile`, `cargo check`, or configure `[ecosystems.cargo].lockfile_commands` if you want cargo to refresh it automatically",
relative_lockfile.display()
);
}
}
}
}
fn resolve_lockfile_command_executions(
root: &Path,
configured_commands: &[LockfileCommandDefinition],
has_released_packages: bool,
) -> MonochangeResult<Vec<LockfileCommandExecution>> {
if !has_released_packages || configured_commands.is_empty() {
return Ok(Vec::new());
}
configured_commands
.iter()
.map(|command| {
let cwd = command
.cwd
.as_ref()
.map_or_else(|| root.to_path_buf(), |cwd| resolve_config_path(root, cwd));
Ok(LockfileCommandExecution {
command: command.command.clone(),
cwd,
shell: command.shell.clone(),
})
})
.collect()
}
fn dedup_lockfile_command_executions(
executions: Vec<LockfileCommandExecution>,
) -> Vec<LockfileCommandExecution> {
let mut seen = BTreeSet::new();
let mut deduped = Vec::new();
for execution in executions {
let key = format!(
"{}::{:?}::{}",
execution.cwd.display(),
execution.shell,
execution.command,
);
if seen.insert(key) {
deduped.push(execution);
}
}
deduped
}
#[must_use = "the validation result must be checked"]
#[cfg(feature = "cargo")]
pub(crate) fn validate_cargo_workspace_version_groups(root: &Path) -> MonochangeResult<()> {
let configuration = load_workspace_configuration(root)?;
if configuration.packages.is_empty() {
return Ok(());
}
let mut packages = discover_cargo_packages(root)?.packages;
if packages.is_empty() {
return Ok(());
}
apply_version_groups(&mut packages, &configuration)?;
monochange_cargo::validate_workspace_version_groups(&packages)
}
#[tracing::instrument(skip_all)]
#[must_use = "the discovery result must be checked"]
pub fn discover_workspace(root: &Path) -> MonochangeResult<DiscoveryReport> {
let configuration = load_workspace_configuration(root)?;
let discovery = build_ecosystem_registry().discover_all(root)?;
let mut warnings = discovery.warnings;
let mut packages = discovery.packages;
normalize_package_ids(root, &mut packages);
packages.sort_by(|left, right| left.id.cmp(&right.id));
packages.dedup_by(|left, right| left.id == right.id);
let (version_groups, version_group_warnings) =
apply_version_groups(&mut packages, &configuration)?;
warnings.extend(version_group_warnings);
let dependencies = materialize_dependency_edges(&packages);
tracing::info!(
packages = packages.len(),
warnings = warnings.len(),
"workspace discovery complete"
);
Ok(DiscoveryReport {
workspace_root: root.to_path_buf(),
packages,
dependencies,
version_groups,
warnings,
})
}
fn discover_release_workspace(
root: &Path,
configuration: &monochange_core::WorkspaceConfiguration,
) -> MonochangeResult<DiscoveryReport> {
if configuration.packages.is_empty() {
return discover_workspace(root);
}
use rayon::prelude::*;
let registry = build_ecosystem_registry();
let mut packages = configuration
.packages
.par_iter()
.map(|package_definition| {
let path = root.join(&package_definition.path);
registry
.load_configured(root, &path, package_definition.package_type.into())?
.ok_or_else(|| {
MonochangeError::Discovery(format!(
"configured package `{}` at {} could not be discovered",
package_definition.id,
package_definition.path.display()
))
})
})
.collect::<MonochangeResult<Vec<_>>>()?;
normalize_package_ids(root, &mut packages);
packages.sort_by(|left, right| left.id.cmp(&right.id));
packages.dedup_by(|left, right| left.id == right.id);
let (version_groups, warnings) = apply_version_groups(&mut packages, configuration)?;
let dependencies = materialize_dependency_edges(&packages);
Ok(DiscoveryReport {
workspace_root: root.to_path_buf(),
packages,
dependencies,
version_groups,
warnings,
})
}
#[derive(Clone, Copy, Debug, TypedBuilder)]
pub struct AddChangeFileRequest<'a> {
pub package_refs: &'a [String],
pub bump: BumpSeverity,
pub reason: &'a str,
#[builder(default)]
pub version: Option<&'a str>,
#[builder(default)]
pub change_type: Option<&'a str>,
#[builder(default)]
pub caused_by: &'a [String],
#[builder(default)]
pub details: Option<&'a str>,
#[builder(default)]
pub output: Option<&'a Path>,
}
pub fn add_change_file(
root: &Path,
request: AddChangeFileRequest<'_>,
) -> MonochangeResult<PathBuf> {
let configuration = load_workspace_configuration(root)?;
let discovery = discover_workspace(root)?;
let packages = canonical_change_packages(
root,
request.package_refs,
&configuration,
&discovery.packages,
)?;
let output_path = request
.output
.map_or_else(|| default_change_path(root, &packages), Path::to_path_buf);
if let Some(parent) = output_path.parent() {
fs::create_dir_all(parent).map_err(|error| {
MonochangeError::Io(format!("failed to create {}: {error}", parent.display()))
})?;
}
if let Some(version) = request.version {
Version::parse(version).map_err(|error| {
MonochangeError::Config(format!(
"invalid explicit version `{version}` passed to `change`: {error}"
))
})?;
}
let content = render_changeset_markdown(
&configuration,
&packages,
request.bump,
request.version,
request.reason,
request.change_type,
request.caused_by,
request.details,
)?;
fs::write(&output_path, content).map_err(|error| {
MonochangeError::Io(format!(
"failed to write {}: {error}",
output_path.display()
))
})?;
Ok(output_path)
}
pub(crate) fn add_interactive_change_file(
root: &Path,
result: &interactive::InteractiveChangeResult,
output: Option<&Path>,
) -> MonochangeResult<PathBuf> {
let output_path = output.map_or_else(
|| {
default_change_path_for_ref(
root,
result.targets.first().map(|target| target.id.as_str()),
)
},
Path::to_path_buf,
);
if let Some(parent) = output_path.parent() {
fs::create_dir_all(parent).map_err(|error| {
MonochangeError::Io(format!("failed to create {}: {error}", parent.display()))
})?;
}
let configuration = load_workspace_configuration(root)?;
let content = render_interactive_changeset_markdown(&configuration, result)?;
fs::write(&output_path, content).map_err(|error| {
MonochangeError::Io(format!(
"failed to write {}: {error}",
output_path.display()
))
})?;
Ok(output_path)
}
pub(crate) fn change_type_default_bump(
configuration: &monochange_core::WorkspaceConfiguration,
_target_id: &str,
change_type: &str,
) -> Option<BumpSeverity> {
let changelog = &configuration.changelog;
changelog.types.get(change_type).map(|typ| typ.bump)
}
fn is_plain_changeset_target_key(target_id: &str) -> bool {
target_id
.chars()
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.'))
}
fn push_double_quoted_yaml_string(rendered: &mut String, value: &str) {
rendered.push('"');
for character in value.chars() {
match character {
'\\' => rendered.push_str("\\\\"),
'"' => rendered.push_str("\\\""),
_ => rendered.push(character),
}
}
rendered.push('"');
}
fn push_changeset_target_key(rendered: &mut String, target_id: &str) {
if is_plain_changeset_target_key(target_id) {
rendered.push_str(target_id);
} else {
push_double_quoted_yaml_string(rendered, target_id);
}
}
fn push_caused_by_list(rendered: &mut String, caused_by: &[String]) {
for (index, reference) in caused_by.iter().enumerate() {
if index > 0 {
rendered.push_str(", ");
}
push_double_quoted_yaml_string(rendered, reference);
}
}
fn push_caused_by_line(rendered: &mut String, caused_by: &[String]) {
rendered.push_str(" caused_by: [");
push_caused_by_list(rendered, caused_by);
rendered.push_str("]\n");
}
fn push_target_object_start(rendered: &mut String, target_id: &str) {
push_changeset_target_key(rendered, target_id);
rendered.push_str(":\n");
}
pub(crate) fn push_change_target_markdown(
rendered: &mut String,
configuration: &monochange_core::WorkspaceConfiguration,
target_id: &str,
bump: BumpSeverity,
version: Option<&str>,
change_type: Option<&str>,
caused_by: &[String],
) -> MonochangeResult<()> {
if change_type.is_none()
&& version.is_none()
&& bump == BumpSeverity::None
&& caused_by.is_empty()
{
return Err(MonochangeError::Config(format!(
"target `{target_id}` must not use a `none` bump without also declaring `type`, `version`, or `caused_by`"
)));
}
let forced_object_syntax = !caused_by.is_empty();
if let Some(change_type) = change_type.filter(|value| !value.trim().is_empty()) {
let default_bump = change_type_default_bump(configuration, target_id, change_type)
.ok_or_else(|| {
MonochangeError::Config(format!(
"target `{target_id}` uses unknown change type `{change_type}`"
))
})?;
if !forced_object_syntax && version.is_none() && bump == default_bump {
push_changeset_target_key(rendered, target_id);
rendered.push_str(": ");
rendered.push_str(change_type);
rendered.push('\n');
return Ok(());
}
push_target_object_start(rendered, target_id);
if bump != BumpSeverity::None {
let _ = writeln!(rendered, " bump: {bump}");
}
let _ = writeln!(rendered, " type: {change_type}");
if let Some(version) = version {
let _ = writeln!(rendered, " version: \"{version}\"");
}
if forced_object_syntax {
push_caused_by_line(rendered, caused_by);
}
return Ok(());
}
if let Some(version) = version {
push_target_object_start(rendered, target_id);
if bump != BumpSeverity::None {
let _ = writeln!(rendered, " bump: {bump}");
}
let _ = writeln!(rendered, " version: \"{version}\"");
if forced_object_syntax {
push_caused_by_line(rendered, caused_by);
}
return Ok(());
}
if forced_object_syntax {
push_target_object_start(rendered, target_id);
let _ = writeln!(rendered, " bump: {bump}");
push_caused_by_line(rendered, caused_by);
return Ok(());
}
push_changeset_target_key(rendered, target_id);
rendered.push_str(": ");
let _ = writeln!(rendered, "{bump}");
Ok(())
}
pub(crate) fn render_interactive_changeset_markdown(
configuration: &monochange_core::WorkspaceConfiguration,
result: &interactive::InteractiveChangeResult,
) -> MonochangeResult<String> {
let mut rendered = String::from("---\n");
for target in &result.targets {
push_change_target_markdown(
&mut rendered,
configuration,
&target.id,
target.bump,
target.version.as_deref(),
target.change_type.as_deref(),
&result.caused_by,
)?;
}
rendered.push_str("---\n\n# ");
rendered.push_str(&result.reason);
rendered.push('\n');
if let Some(details) = result
.details
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
rendered.push('\n');
rendered.push_str(details);
rendered.push('\n');
}
Ok(rendered)
}
#[must_use = "the release plan result must be checked"]
pub fn plan_release(root: &Path, changes_path: &Path) -> MonochangeResult<ReleasePlan> {
let configuration = load_workspace_configuration(root)?;
let discovery = discover_workspace(root)?;
let change_signals = load_change_signals(changes_path, &configuration, &discovery.packages)?;
build_release_plan_from_signals(&configuration, &discovery, &change_signals)
}
#[tracing::instrument(skip_all)]
fn materialize_lockfile_command_updates(
root: &Path,
base_updates: &[FileUpdate],
lockfile_commands: &[LockfileCommandExecution],
) -> MonochangeResult<Vec<FileUpdate>> {
let lockfile_dirs: Vec<PathBuf> = lockfile_commands
.iter()
.map(|cmd| cmd.cwd.clone())
.collect();
let mut before_snapshots = BTreeMap::new();
for dir in &lockfile_dirs {
let full_dir = root.join(dir);
if full_dir.is_dir() {
snapshot_directory_files(root, &full_dir, &mut before_snapshots)?;
}
}
apply_file_updates(base_updates)?;
for command in lockfile_commands {
run_lockfile_command_in_place(root, command)?;
}
let mut after_snapshots = BTreeMap::new();
for dir in &lockfile_dirs {
let full_dir = root.join(dir);
if full_dir.is_dir() {
snapshot_directory_files(root, &full_dir, &mut after_snapshots)?;
}
}
let mut all_updates = base_updates.to_vec();
for (relative_path, after_content) in &after_snapshots {
let before = before_snapshots.get(relative_path);
if before != Some(after_content) {
all_updates.push(FileUpdate {
path: root.join(relative_path),
content: after_content.clone(),
});
}
}
all_updates.sort_by(|a, b| a.path.cmp(&b.path));
all_updates.dedup_by(|a, b| a.path == b.path);
Ok(all_updates)
}
fn snapshot_directory_files(
root: &Path,
dir: &Path,
snapshots: &mut BTreeMap<PathBuf, Vec<u8>>,
) -> MonochangeResult<()> {
let entries = fs::read_dir(dir).map_err(|error| {
MonochangeError::Io(format!("failed to read {}: {error}", dir.display()))
})?;
for entry in entries {
let entry = entry
.map_err(|error| MonochangeError::Io(format!("directory entry error: {error}")))?;
let path = entry.path();
if path.is_file() {
let relative = path.strip_prefix(root).unwrap_or(&path).to_path_buf();
let content = fs::read(&path).map_err(|error| {
MonochangeError::Io(format!("failed to read {}: {error}", path.display()))
})?;
snapshots.insert(relative, content);
}
}
Ok(())
}
fn run_lockfile_command_in_place(
root: &Path,
command: &LockfileCommandExecution,
) -> MonochangeResult<()> {
let cwd = root.join(&command.cwd);
let output = if let Some(shell_binary) = command.shell.shell_binary() {
ProcessCommand::new(shell_binary)
.arg("-c")
.arg(&command.command)
.current_dir(&cwd)
.output()
} else {
let parts = shlex::split(&command.command).ok_or_else(|| {
MonochangeError::Config(format!("failed to parse command `{}`", command.command))
})?;
let Some((program, args)) = parts.split_first() else {
return Err(MonochangeError::Config(
"lockfile command must not be empty".to_string(),
));
};
ProcessCommand::new(program)
.args(args)
.current_dir(&cwd)
.output()
};
let output = output.map_err(|error| {
MonochangeError::Io(format!(
"failed to run lockfile command `{}` in {}: {error}",
command.command,
root_relative(root, &command.cwd).display(),
))
})?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let details = if stderr.is_empty() {
format!("exit status {}", output.status)
} else {
stderr
};
Err(MonochangeError::Config(format!(
"lockfile command `{}` failed in {}: {details}",
command.command,
root_relative(root, &command.cwd).display(),
)))
}
#[cfg(test)]
use monochange_test_helpers::workspace_ops::collect_workspace_files;
#[cfg(test)]
use monochange_test_helpers::workspace_ops::copy_workspace_file;
#[cfg(test)]
use monochange_test_helpers::workspace_ops::copy_workspace_tree;
#[cfg(test)]
use monochange_test_helpers::workspace_ops::ensure_parent_directory;
#[cfg(test)]
use monochange_test_helpers::workspace_ops::read_optional_file;
#[cfg(test)]
use monochange_test_helpers::workspace_ops::remap_workspace_path;
#[cfg(test)]
use monochange_test_helpers::workspace_ops::run_lockfile_command;
#[cfg(test)]
use monochange_test_helpers::workspace_ops::strip_workspace_prefix;
#[cfg(test)]
fn collect_workspace_file_updates(
root: &Path,
temp_root: &Path,
base_updates: &[FileUpdate],
lockfile_commands: &[LockfileCommandExecution],
) -> MonochangeResult<Vec<FileUpdate>> {
let normalized_root = monochange_core::normalize_path(root);
let mut dirs_to_scan = BTreeSet::new();
for update in base_updates {
if let Some(parent) = update.path.parent() {
let normalized = monochange_core::normalize_path(parent);
if let Ok(relative) = normalized.strip_prefix(&normalized_root) {
dirs_to_scan.insert(relative.to_path_buf());
}
}
}
for command in lockfile_commands {
let normalized = monochange_core::normalize_path(&command.cwd);
if let Ok(relative) = normalized.strip_prefix(&normalized_root) {
dirs_to_scan.insert(relative.to_path_buf());
} else {
dirs_to_scan.insert(command.cwd.clone());
}
}
dirs_to_scan.insert(PathBuf::from(".changeset"));
let mut relative_paths = BTreeSet::new();
for dir in &dirs_to_scan {
let original_dir = root.join(dir);
let temp_dir = temp_root.join(dir);
if original_dir.is_dir() {
collect_workspace_files(root, &original_dir, &mut relative_paths)?;
}
if temp_dir.is_dir() {
collect_workspace_files(temp_root, &temp_dir, &mut relative_paths)?;
}
}
let mut updates = Vec::new();
for relative in relative_paths {
let before = read_optional_file(&root.join(&relative))?;
let after = read_optional_file(&temp_root.join(&relative))?;
if let Some(content) = after.filter(|content| before.as_ref() != Some(content)) {
updates.push(FileUpdate {
path: root.join(relative),
content,
});
}
}
updates.sort_by(|left, right| left.path.cmp(&right.path));
Ok(updates)
}
#[must_use = "the prepared release result must be checked"]
pub async fn prepare_release(root: &Path, dry_run: bool) -> MonochangeResult<PreparedRelease> {
prepare_release_execution_with_file_diffs(root, dry_run, false, false)
.await
.map(|execution| execution.prepared_release)
}
#[cfg(test)]
#[tracing::instrument(skip_all, fields(dry_run))]
pub(crate) async fn prepare_release_execution(
root: &Path,
dry_run: bool,
) -> MonochangeResult<PreparedReleaseExecution> {
prepare_release_execution_with_file_diffs(root, dry_run, true, false).await
}
#[tracing::instrument(skip_all, fields(dry_run, build_file_diffs))]
pub(crate) async fn prepare_release_execution_with_file_diffs(
root: &Path,
dry_run: bool,
build_file_diffs: bool,
allow_empty_changesets: bool,
) -> MonochangeResult<PreparedReleaseExecution> {
let mut phase_timings = Vec::new();
let configuration =
measure_prepare_phase(&mut phase_timings, "load workspace configuration", || {
load_workspace_configuration(root)
})?;
let discovery =
measure_prepare_phase(&mut phase_timings, "discover release workspace", || {
discover_release_workspace(root, &configuration)
})?;
let previous_prerelease_state =
measure_prepare_phase(&mut phase_timings, "load prerelease state", || {
load_prerelease_state(root)
})?;
let planning_discovery =
if configuration.prerelease.enabled || previous_prerelease_state.is_some() {
discovery_with_prerelease_baselines(
&discovery,
previous_prerelease_state.as_ref(),
&configuration.prerelease,
)
} else {
discovery.clone()
};
let prerelease_allows_empty_changesets = configuration.prerelease.enabled;
let changeset_paths =
measure_prepare_phase(&mut phase_timings, "discover changeset paths", || {
discover_changeset_paths(
root,
allow_empty_changesets || prerelease_allows_empty_changesets,
)
})?;
tracing::debug!(count = changeset_paths.len(), "discovered changesets");
if changeset_paths.is_empty() && allow_empty_changesets && !prerelease_allows_empty_changesets {
return Ok(PreparedReleaseExecution {
prepared_release: PreparedRelease {
plan: ReleasePlan {
workspace_root: root.to_path_buf(),
decisions: Vec::new(),
groups: Vec::new(),
warnings: Vec::new(),
unresolved_items: Vec::new(),
compatibility_evidence: Vec::new(),
},
changeset_paths,
changesets: Vec::new(),
released_packages: Vec::new(),
package_publications: Vec::new(),
version: None,
group_version: None,
release_targets: Vec::new(),
changed_files: Vec::new(),
changelogs: Vec::new(),
updated_changelogs: Vec::new(),
deleted_changesets: Vec::new(),
dry_run,
},
file_diffs: Vec::new(),
phase_timings,
});
}
let changeset_context = build_changeset_load_context(&configuration, &discovery.packages);
let changeset_sources =
measure_prepare_phase(&mut phase_timings, "read changeset files", || {
changeset_paths
.iter()
.map(|path| Ok((path.clone(), read_changeset_source(path)?)))
.collect::<MonochangeResult<Vec<_>>>()
})?;
let loaded_changesets =
measure_prepare_phase(&mut phase_timings, "parse changeset files", || {
use rayon::prelude::*;
changeset_sources
.par_iter()
.map(|(path, contents)| {
load_changeset_contents_with_context(path, contents, &changeset_context)
})
.collect::<MonochangeResult<Vec<_>>>()
})?;
let change_signals = loaded_changesets
.iter()
.flat_map(|changeset| changeset.signals.clone())
.collect::<Vec<_>>();
let prepared_changesets =
measure_prepare_phase(&mut phase_timings, "build prepared changesets", || {
Ok(build_prepared_changesets(root, loaded_changesets))
})?;
let mut changesets = Some(prepared_changesets);
let background_changeset_context =
configuration
.source
.as_ref()
.filter(|_| !dry_run)
.map(|source| {
assert!(changesets.is_some());
spawn_source_changeset_context_task(
source.clone(),
dry_run,
changesets.take().unwrap_or_default(),
)
});
if let Some(source) = configuration.source.as_ref().filter(|_| dry_run) {
apply_source_changeset_context_with_timing(
&mut phase_timings,
source,
dry_run,
changesets
.as_mut()
.unwrap_or_else(|| panic!("changesets should exist for dry-run annotation")),
)
.await;
}
let mut plan = measure_prepare_phase(&mut phase_timings, "build release plan", || {
if configuration.prerelease.enabled && change_signals.is_empty() {
Ok(synthesize_no_changeset_prerelease_plan(&planning_discovery))
} else {
build_release_plan_from_signals(&configuration, &planning_discovery, &change_signals)
}
})?;
if configuration.prerelease.enabled {
measure_prepare_phase(&mut phase_timings, "apply prerelease versions", || {
apply_prerelease_versions_to_plan(
&mut plan,
&planning_discovery,
previous_prerelease_state.as_ref(),
&configuration.prerelease,
)
})?;
}
let released_packages = released_package_names(&discovery.packages, &plan);
tracing::debug!(
count = released_packages.len(),
"identified released packages"
);
if released_packages.is_empty() {
return Err(MonochangeError::Config(
"no releaseable packages were found in discovered changesets".to_string(),
));
}
let (
(changelog_targets_result, manifest_updates_result),
(versioned_file_updates_result, lockfile_commands_result),
) = rayon::join(
|| {
rayon::join(
|| {
capture_prepare_phase("resolve changelog targets", || {
resolve_changelog_targets(&configuration, &discovery.packages)
})
},
|| {
capture_prepare_phase("build manifest updates", || {
build_manifest_updates_parallel(&discovery.packages, &plan)
})
},
)
},
|| {
rayon::join(
|| {
capture_prepare_phase("build versioned file updates", || {
build_versioned_file_updates(
root,
&configuration,
&discovery.packages,
&plan,
)
})
},
|| {
capture_prepare_phase("build lockfile refresh plan", || {
build_lockfile_command_executions(
root,
&configuration,
&discovery.packages,
&plan,
)
})
},
)
},
);
phase_timings.extend([
changelog_targets_result.1,
manifest_updates_result.1,
versioned_file_updates_result.1,
lockfile_commands_result.1,
]);
let changelog_targets = changelog_targets_result.0?;
let manifest_updates =
if configuration.prerelease.enabled && !configuration.prerelease.write_manifests {
Vec::new()
} else {
manifest_updates_result.0?
};
let versioned_file_updates =
if configuration.prerelease.enabled && !configuration.prerelease.write_manifests {
Vec::new()
} else {
versioned_file_updates_result.0?
};
let release_targets = measure_async_prepare_phase(
&mut phase_timings,
"build release targets",
build_release_targets(&configuration, &discovery.packages, &plan, &changeset_paths),
)
.await;
let lockfile_commands = lockfile_commands_result.0?;
let mut package_publications =
build_package_publication_targets(&configuration, &discovery.packages, &plan);
if configuration.prerelease.enabled && !configuration.prerelease.publish_packages {
package_publications.clear();
}
let changesets = if let Some(handle) = background_changeset_context {
join_source_changeset_context_task(&mut phase_timings, handle).await?
} else {
changesets
.take()
.unwrap_or_else(|| panic!("changesets should be available after local planning"))
};
let changelog_release_targets = release_targets
.iter()
.map(|target| {
monochange_changelog::ReleaseTarget {
id: target.id.clone(),
kind: target.kind,
version: target.version.clone(),
tag: target.tag,
release: target.release,
version_format: target.version_format,
tag_name: target.tag_name.clone(),
members: target.members.clone(),
rendered_title: target.rendered_title.clone(),
rendered_changelog_title: target.rendered_changelog_title.clone(),
}
})
.collect::<Vec<_>>();
let changelog_updates =
if configuration.prerelease.enabled && !configuration.prerelease.changelog {
Vec::new()
} else {
measure_prepare_phase(&mut phase_timings, "build changelog updates", || {
build_changelog_updates(
ChangelogBuildContext::builder()
.root(root)
.configuration(&configuration)
.packages(&discovery.packages)
.plan(&plan)
.change_signals(&change_signals)
.changesets(&changesets)
.changelog_targets(&changelog_targets)
.release_targets(&changelog_release_targets)
.build(),
)
})?
};
let changelog_file_updates = changelog_updates
.iter()
.map(|update| {
FileUpdate {
path: update.file.path.clone(),
content: update.file.content.clone(),
}
})
.collect::<Vec<_>>();
let prerelease_prepared_state = if configuration.prerelease.enabled {
Some(build_prerelease_state_update(
root,
&plan,
&planning_discovery,
previous_prerelease_state.as_ref(),
&configuration.prerelease,
)?)
} else {
None
};
let prerelease_state_updates = prerelease_prepared_state
.as_ref()
.map(|prepared| vec![prepared.state_update.clone()])
.unwrap_or_default();
let base_updates = [
manifest_updates.clone(),
versioned_file_updates.clone(),
changelog_file_updates.clone(),
prerelease_state_updates,
]
.concat();
tracing::debug!(
manifest_updates = manifest_updates.len(),
lockfile_commands = lockfile_commands.len(),
"built manifest and lockfile updates"
);
let file_updates = if lockfile_commands.is_empty() || dry_run {
base_updates.clone()
} else {
materialize_lockfile_command_updates_with_timing(
&mut phase_timings,
root,
&base_updates,
&lockfile_commands,
)?
};
let mut changed_files = file_updates
.iter()
.map(|update| root_relative(root, &update.path))
.collect::<Vec<_>>();
changed_files.sort();
changed_files.dedup();
let changelogs = changelog_updates
.iter()
.map(|update| {
PreparedChangelog {
owner_id: update.owner_id.clone(),
owner_kind: update.owner_kind,
path: root_relative(root, &update.file.path),
format: update.format,
notes: update.notes.clone(),
rendered: update.rendered.clone(),
}
})
.collect::<Vec<_>>();
let updated_changelogs = changelogs
.iter()
.map(|update| update.path.clone())
.collect::<Vec<_>>();
let file_diffs = if build_file_diffs {
measure_prepare_phase(&mut phase_timings, "build file diff previews", || {
build_file_diff_previews(root, &file_updates)
})?
} else {
Vec::new()
};
let version = shared_release_version(&plan);
let group_version = shared_group_version(&plan);
let mut deleted_changesets = Vec::new();
if !dry_run {
measure_prepare_phase(&mut phase_timings, "apply release changes", || {
if lockfile_commands.is_empty() {
apply_file_updates(&file_updates)?;
}
if !(configuration.prerelease.enabled && configuration.prerelease.keep_changesets) {
for path in &changeset_paths {
delete_changeset_file(path)?;
deleted_changesets.push(root_relative(root, path));
}
}
if !configuration.prerelease.enabled && previous_prerelease_state.is_some() {
let path = prerelease_state_path(root);
if path.exists() {
fs::remove_file(&path).map_err(|error| {
MonochangeError::Io(format!("failed to delete {}: {error}", path.display()))
})?;
}
}
Ok(())
})?;
}
tracing::info!(
changed_files = changed_files.len(),
dry_run,
"release preparation complete"
);
Ok(PreparedReleaseExecution {
prepared_release: PreparedRelease {
plan,
changeset_paths,
changesets,
released_packages,
package_publications,
version,
group_version,
release_targets,
changed_files,
changelogs,
updated_changelogs,
deleted_changesets,
dry_run,
},
file_diffs,
phase_timings,
})
}
fn measure_prepare_phase<T>(
phase_timings: &mut Vec<StepPhaseTiming>,
label: impl Into<String>,
action: impl FnOnce() -> MonochangeResult<T>,
) -> MonochangeResult<T> {
let label = label.into();
let started_at = Instant::now();
let result = action();
record_prepare_phase_timing(phase_timings, label, started_at);
result
}
async fn measure_async_prepare_phase<T>(
phase_timings: &mut Vec<StepPhaseTiming>,
label: impl Into<String>,
future: impl Future<Output = T>,
) -> T {
let label = label.into();
let started_at = Instant::now();
let result = future.await;
record_prepare_phase_timing(phase_timings, label, started_at);
result
}
fn capture_prepare_phase<T>(
label: impl Into<String>,
action: impl FnOnce() -> MonochangeResult<T>,
) -> (MonochangeResult<T>, StepPhaseTiming) {
let label = label.into();
let started_at = Instant::now();
let result = action();
(
result,
StepPhaseTiming {
label,
duration: started_at.elapsed(),
},
)
}
fn record_prepare_phase_timing(
phase_timings: &mut Vec<StepPhaseTiming>,
label: impl Into<String>,
started_at: Instant,
) {
phase_timings.push(StepPhaseTiming {
label: label.into(),
duration: started_at.elapsed(),
});
}
fn read_changeset_source(path: &Path) -> MonochangeResult<String> {
fs::read_to_string(path)
.map_err(|error| MonochangeError::Io(format!("failed to read {}: {error}", path.display())))
}
fn delete_changeset_file(path: &Path) -> MonochangeResult<()> {
fs::remove_file(path).map_err(|error| {
MonochangeError::Io(format!("failed to delete {}: {error}", path.display()))
})
}
fn changeset_context_phase_label(source: &SourceConfiguration, dry_run: bool) -> String {
if dry_run {
format!("annotate changeset context via {}", source.provider)
} else {
format!("enrich changeset context via {}", source.provider)
}
}
async fn apply_source_changeset_context_with_timing(
phase_timings: &mut Vec<StepPhaseTiming>,
source: &SourceConfiguration,
dry_run: bool,
changesets: &mut [PreparedChangeset],
) {
let label = changeset_context_phase_label(source, dry_run);
let started_at = Instant::now();
apply_source_changeset_context(source, dry_run, changesets).await;
record_prepare_phase_timing(phase_timings, label, started_at);
}
struct SourceChangesetContextTask {
handle: JoinHandle<(Vec<PreparedChangeset>, StepPhaseTiming)>,
}
impl SourceChangesetContextTask {
fn new(handle: JoinHandle<(Vec<PreparedChangeset>, StepPhaseTiming)>) -> Self {
Self { handle }
}
}
impl Drop for SourceChangesetContextTask {
fn drop(&mut self) {
self.handle.abort();
}
}
fn spawn_source_changeset_context_task(
source: SourceConfiguration,
dry_run: bool,
mut changesets: Vec<PreparedChangeset>,
) -> SourceChangesetContextTask {
SourceChangesetContextTask::new(tokio::spawn(async move {
let label = changeset_context_phase_label(&source, dry_run);
let started_at = Instant::now();
apply_source_changeset_context(&source, dry_run, &mut changesets).await;
(
changesets,
StepPhaseTiming {
label,
duration: started_at.elapsed(),
},
)
}))
}
async fn join_source_changeset_context_task(
phase_timings: &mut Vec<StepPhaseTiming>,
mut task: SourceChangesetContextTask,
) -> MonochangeResult<Vec<PreparedChangeset>> {
let (changesets, timing) = (&mut task.handle).await.map_err(|_| {
MonochangeError::Io("background changeset context enrichment panicked".to_string())
})?;
phase_timings.push(timing);
Ok(changesets)
}
async fn apply_source_changeset_context(
source: &SourceConfiguration,
dry_run: bool,
changesets: &mut [PreparedChangeset],
) {
let adapter = hosted_sources::configured_hosted_source_adapter(source);
if dry_run {
adapter.annotate_changeset_context(source, changesets);
} else {
run_changeset_context_enrichment_with_timeout(
source,
changeset_context_timeout(source),
adapter.enrich_changeset_context(source, changesets),
)
.await;
}
}
fn changeset_context_timeout(source: &SourceConfiguration) -> Duration {
Duration::from_secs(source.releases.changeset_context_timeout_seconds)
}
async fn run_changeset_context_enrichment_with_timeout(
source: &SourceConfiguration,
timeout_after: Duration,
enrichment: impl Future<Output = ()>,
) -> bool {
if timeout(timeout_after, enrichment).await.is_ok() {
return true;
}
let timeout_ms = timeout_after.as_millis();
tracing::warn!(provider = %source.provider, timeout_ms, "timed out enriching changeset context");
false
}
fn materialize_lockfile_command_updates_with_timing(
phase_timings: &mut Vec<StepPhaseTiming>,
root: &Path,
base_updates: &[FileUpdate],
lockfile_commands: &[LockfileCommandExecution],
) -> MonochangeResult<Vec<FileUpdate>> {
let started_at = Instant::now();
let result = materialize_lockfile_command_updates(root, base_updates, lockfile_commands);
record_prepare_phase_timing(
phase_timings,
"materialize lockfile command updates",
started_at,
);
result
}
#[cfg(test)]
#[path = "__tests__/workspace_ops_tests.rs"]
mod workspace_ops_tests;