#[cfg(test)]
use std::cell::Cell;
use std::io::BufRead;
use std::io::BufReader;
use std::io::BufWriter;
use std::io::IsTerminal;
use similar::TextDiff;
use super::*;
use crate::git_support::git_stage_all;
#[cfg(test)]
thread_local! {
pub(crate) static FORCE_BUILD_FILE_DIFF_PREVIEWS_ERROR: Cell<bool> = const { Cell::new(false) };
}
thread_local! {
pub(crate) static DEDUPLICATED_CACHE: std::cell::RefCell<std::collections::HashSet<(PathBuf, String)>> =
std::cell::RefCell::new(std::collections::HashSet::new());
}
const DEDUP_INDEX_PATH: &str = ".monochange/local/release-index.jsonl";
fn load_dedup_index(root: &Path) -> std::collections::HashSet<String> {
let path = root.join(DEDUP_INDEX_PATH);
let Ok(file) = fs::File::open(&path) else {
return std::collections::HashSet::new();
};
let reader = BufReader::new(file);
load_dedup_index_from_reader(reader).unwrap_or_default()
}
fn load_dedup_index_from_reader(reader: impl BufRead) -> Option<std::collections::HashSet<String>> {
let mut index = std::collections::HashSet::new();
for line in reader.lines() {
let Ok(line) = line else {
return None;
};
let line = line.trim();
if line.is_empty() {
continue;
}
if let Some(hash) = parse_dedup_index_hash(line) {
index.insert(hash.to_owned());
}
}
Some(index)
}
fn parse_dedup_index_hash(line: &str) -> Option<&str> {
#[derive(serde::Deserialize)]
struct DedupIndexEntry<'a> {
#[serde(borrow)]
hash: &'a str,
}
serde_json::from_str::<DedupIndexEntry<'_>>(line)
.ok()
.map(|entry| entry.hash)
}
fn save_dedup_index(
root: &Path,
index: &std::collections::HashSet<String>,
) -> MonochangeResult<()> {
let path = root.join(DEDUP_INDEX_PATH);
let parent = path.parent().unwrap_or(root);
fs::create_dir_all(parent)
.map_err(|error| MonochangeError::Io(format!("create dedup index dir: {error}")))?;
let mut hashes = index.iter().map(String::as_str).collect::<Vec<_>>();
hashes.sort_unstable();
let temp = path.with_extension("tmp");
let file = fs::File::create(&temp)
.map_err(|error| MonochangeError::Io(format!("write dedup index: {error}")))?;
let mut writer = BufWriter::new(file);
for (position, hash) in hashes.iter().enumerate() {
if position > 0 {
std::io::Write::write_all(&mut writer, b"\n")
.map_err(|error| MonochangeError::Io(format!("write dedup index: {error}")))?;
}
std::io::Write::write_fmt(&mut writer, format_args!(r#"{{"hash":"{hash}"}}"#))
.map_err(|error| MonochangeError::Io(format!("write dedup index: {error}")))?;
}
std::io::Write::flush(&mut writer)
.map_err(|error| MonochangeError::Io(format!("write dedup index: {error}")))?;
fs::rename(&temp, &path)
.map_err(|error| MonochangeError::Io(format!("rename dedup index: {error}")))?;
Ok(())
}
fn add_to_dedup_index(root: &Path, hash: &str) -> MonochangeResult<()> {
let mut index = load_dedup_index(root);
index.insert(hash.to_string());
save_dedup_index(root, &index)
}
fn remove_from_dedup_index(root: &Path, hash: &str) -> MonochangeResult<()> {
let mut index = load_dedup_index(root);
index.remove(hash);
save_dedup_index(root, &index)
}
pub(crate) async fn build_release_targets(
configuration: &monochange_core::WorkspaceConfiguration,
packages: &[PackageRecord],
plan: &ReleasePlan,
changeset_paths: &[PathBuf],
) -> Vec<ReleaseTarget> {
let changes_count = changeset_paths.len();
let package_by_id = packages
.iter()
.map(|package| (package.id.as_str(), package))
.collect::<BTreeMap<_, _>>();
let source = configuration.source.as_ref();
let defaults_release_title = configuration.defaults.release_title.as_deref();
let defaults_changelog_title = configuration.defaults.changelog_version_title.as_deref();
let sorted_tags = load_sorted_tags(&configuration.root_path).await;
let configured_package_by_id = configuration
.packages
.iter()
.map(|package| (package.id.as_str(), package))
.collect::<std::collections::HashMap<_, _>>();
let mut group_by_package_id = std::collections::HashMap::with_capacity(
configuration
.groups
.iter()
.map(|group| group.packages.len())
.sum(),
);
for group in &configuration.groups {
for package_id in &group.packages {
group_by_package_id
.entry(package_id.as_str())
.or_insert(group);
}
}
let planned_group_by_id = plan
.groups
.iter()
.filter(|group| group.recommended_bump.is_release())
.map(|group| (group.group_id.as_str(), group))
.collect::<std::collections::HashMap<_, _>>();
let mut release_targets = Vec::with_capacity(configuration.groups.len() + plan.decisions.len());
release_targets.extend(configuration.groups.iter().filter_map(|group| {
planned_group_by_id.get(group.id.as_str()).and_then(|pg| {
pg.planned_version.as_ref().map(|version| {
let vs = version.to_string();
let tag = render_tag_name(&group.id, &vs, group.version_format);
let prev = find_previous_tag_in(&tag, &sorted_tags);
let ctx = TitleRenderContext::new(
&group.id,
&vs,
changes_count,
source,
&tag,
prev.as_deref(),
);
let rt = effective_title_template(
group.release_title.as_deref(),
defaults_release_title,
default_release_title_for_format(group.version_format),
);
let ct = effective_title_template(
group.changelog_version_title.as_deref(),
defaults_changelog_title,
default_changelog_version_title_for_format(group.version_format),
);
ReleaseTarget {
id: group.id.clone(),
kind: ReleaseOwnerKind::Group,
version: vs,
tag: group.tag,
release: group.release,
version_format: group.version_format,
tag_name: tag,
members: group.packages.clone(),
rendered_title: ctx.render(rt),
rendered_changelog_title: ctx.render(ct),
}
})
})
}));
for decision in plan
.decisions
.iter()
.filter(|d| d.recommended_bump.is_release() && d.group_id.is_none())
{
let Some(package) = package_by_id.get(decision.package_id.as_str()).copied() else {
continue;
};
let Some(version) = decision.planned_version.as_ref() else {
continue;
};
let config_id = package
.metadata
.get("config_id")
.cloned()
.unwrap_or_else(|| package.name.clone());
let Some(package_definition) = configured_package_by_id.get(config_id.as_str()).copied()
else {
continue;
};
let (owner_id, owner_kind, tag_enabled, release_enabled, version_format, members) =
if let Some(group) = group_by_package_id.get(config_id.as_str()).copied() {
(
&group.id,
ReleaseOwnerKind::Group,
group.tag,
group.release,
group.version_format,
group.packages.clone(),
)
} else {
(
&package_definition.id,
ReleaseOwnerKind::Package,
package_definition.tag,
package_definition.release,
package_definition.version_format,
vec![package_definition.id.clone()],
)
};
let vs = version.to_string();
let tag = render_tag_name(owner_id, &vs, version_format);
let prev = find_previous_tag_in(&tag, &sorted_tags);
let ctx =
TitleRenderContext::new(owner_id, &vs, changes_count, source, &tag, prev.as_deref());
let rt = effective_title_template(
package_definition.release_title.as_deref(),
defaults_release_title,
default_release_title_for_format(version_format),
);
let ct = effective_title_template(
package_definition.changelog_version_title.as_deref(),
defaults_changelog_title,
default_changelog_version_title_for_format(version_format),
);
release_targets.push(ReleaseTarget {
id: owner_id.clone(),
kind: owner_kind,
version: vs,
tag: tag_enabled,
release: release_enabled,
version_format,
tag_name: tag,
members,
rendered_title: ctx.render(rt),
rendered_changelog_title: ctx.render(ct),
});
}
release_targets.sort_by(|left, right| left.id.cmp(&right.id));
release_targets
}
pub(crate) fn build_package_publication_targets(
configuration: &monochange_core::WorkspaceConfiguration,
packages: &[PackageRecord],
plan: &ReleasePlan,
) -> Vec<PackagePublicationTarget> {
let package_by_id = packages
.iter()
.map(|package| (package.id.as_str(), package))
.collect::<std::collections::HashMap<_, _>>();
let configured_package_by_id = configuration
.packages
.iter()
.map(|package| (package.id.as_str(), package))
.collect::<std::collections::HashMap<_, _>>();
let mut targets = plan
.decisions
.iter()
.filter(|decision| decision.recommended_bump.is_release())
.filter_map(|decision| {
let package = package_by_id.get(decision.package_id.as_str()).copied()?;
let version = decision.planned_version.as_ref()?;
let config_id = package
.metadata
.get("config_id")
.cloned()
.unwrap_or_else(|| package.name.clone());
let package_definition = configured_package_by_id.get(config_id.as_str()).copied()?;
if !package_definition.publish.enabled
|| matches!(
package.publish_state,
monochange_core::PublishState::Private
| monochange_core::PublishState::Excluded
) {
return None;
}
Some(PackagePublicationTarget {
package: config_id,
ecosystem: package.ecosystem,
registry: package_definition.publish.registry.clone(),
version: version.to_string(),
mode: package_definition.publish.mode,
trusted_publishing: package_definition.publish.trusted_publishing.clone(),
attestations: package_definition.publish.attestations.clone(),
})
})
.collect::<Vec<_>>();
targets.sort_by(|left, right| left.package.cmp(&right.package));
targets
}
pub(crate) fn build_manifest_updates_parallel(
packages: &[PackageRecord],
plan: &ReleasePlan,
) -> MonochangeResult<Vec<FileUpdate>> {
#[cfg(all(feature = "cargo", feature = "npm", feature = "deno", feature = "dart"))]
{
let ((cargo_updates, npm_updates), (deno_updates, dart_updates)) = rayon::join(
|| {
rayon::join(
|| build_cargo_manifest_updates(packages, plan),
|| build_npm_manifest_updates(packages, plan),
)
},
|| {
rayon::join(
|| build_deno_manifest_updates(packages, plan),
|| build_dart_manifest_updates(packages, plan),
)
},
);
Ok([cargo_updates?, npm_updates?, deno_updates?, dart_updates?].concat())
}
#[cfg(not(all(feature = "cargo", feature = "npm", feature = "deno", feature = "dart")))]
{
let mut updates = Vec::new();
#[cfg(feature = "cargo")]
updates.extend(build_cargo_manifest_updates(packages, plan)?);
#[cfg(feature = "npm")]
updates.extend(build_npm_manifest_updates(packages, plan)?);
#[cfg(feature = "deno")]
updates.extend(build_deno_manifest_updates(packages, plan)?);
#[cfg(feature = "dart")]
updates.extend(build_dart_manifest_updates(packages, plan)?);
Ok(updates)
}
}
#[allow(clippy::match_same_arms)]
pub(crate) fn render_tag_name(id: &str, version: &str, version_format: VersionFormat) -> String {
match version_format {
VersionFormat::Namespaced => format!("{id}/v{version}"),
VersionFormat::Primary => format!("v{version}"),
_ => format!("v{version}"),
}
}
pub(crate) fn tag_url_for_provider(source: &SourceConfiguration, tag_name: &str) -> String {
match source.provider {
#[cfg(feature = "github")]
SourceProvider::GitHub => github_provider::tag_url(source, tag_name),
#[cfg(feature = "gitlab")]
SourceProvider::GitLab => gitlab_provider::tag_url(source, tag_name),
#[cfg(feature = "gitea")]
SourceProvider::Gitea => gitea_provider::tag_url(source, tag_name),
#[cfg(feature = "forgejo")]
SourceProvider::Forgejo => forgejo_provider::tag_url(source, tag_name),
#[cfg(not(any(
feature = "github",
feature = "gitlab",
feature = "gitea",
feature = "forgejo"
)))]
_ => String::new(),
}
}
pub(crate) fn compare_url_for_provider(
source: &SourceConfiguration,
previous_tag: &str,
current_tag: &str,
) -> String {
match source.provider {
#[cfg(feature = "github")]
SourceProvider::GitHub => github_provider::compare_url(source, previous_tag, current_tag),
#[cfg(feature = "gitlab")]
SourceProvider::GitLab => gitlab_provider::compare_url(source, previous_tag, current_tag),
#[cfg(feature = "gitea")]
SourceProvider::Gitea => gitea_provider::compare_url(source, previous_tag, current_tag),
#[cfg(feature = "forgejo")]
SourceProvider::Forgejo => forgejo_provider::compare_url(source, previous_tag, current_tag),
#[cfg(not(any(
feature = "github",
feature = "gitlab",
feature = "gitea",
feature = "forgejo"
)))]
_ => String::new(),
}
}
async fn load_sorted_tags(root: &Path) -> Vec<String> {
let output = match monochange_core::git::git_command_output(
root,
&["tag", "--list", "--sort=-v:refname"],
)
.await
{
Ok(output) if output.status.success() => output,
_ => return Vec::new(),
};
String::from_utf8_lossy(&output.stdout)
.lines()
.map(str::trim)
.filter(|tag| !tag.is_empty())
.map(ToString::to_string)
.collect()
}
fn find_previous_tag_in(current_tag: &str, sorted_tags: &[String]) -> Option<String> {
let (prefix, current_version) = parse_tag_prefix_and_version(current_tag)?;
sorted_tags
.iter()
.filter(|tag| tag.as_str() != current_tag)
.filter_map(|tag| {
let (candidate_prefix, candidate_version) = parse_tag_prefix_and_version(tag)?;
(candidate_prefix == prefix && candidate_version < current_version)
.then(|| (tag.clone(), candidate_version))
})
.max_by(|left, right| left.1.cmp(&right.1))
.map(|(tag, _)| tag)
}
#[cfg(test)]
pub(crate) async fn find_previous_tag(root: &Path, current_tag: &str) -> Option<String> {
find_previous_tag_in(current_tag, &load_sorted_tags(root).await)
}
pub(crate) fn parse_tag_prefix_and_version(tag: &str) -> Option<(String, semver::Version)> {
let v_pos = tag.rfind('v')?;
let prefix = &tag[..=v_pos];
let version_str = &tag[v_pos + 1..];
let version = semver::Version::parse(version_str).ok()?;
Some((prefix.to_string(), version))
}
struct TitleRenderContext {
id: String,
version: String,
previous_version: String,
date: String,
time: String,
datetime: String,
changes_count: usize,
tag_url: String,
compare_url: String,
}
impl TitleRenderContext {
fn new(
id: &str,
version: &str,
changes_count: usize,
source: Option<&SourceConfiguration>,
tag_name: &str,
previous_tag_name: Option<&str>,
) -> Self {
let now = resolve_release_datetime();
let date = now.format("%Y-%m-%d").to_string();
let time = now.format("%H:%M:%S").to_string();
let datetime = now.format("%Y-%m-%dT%H:%M:%S").to_string();
let tag_url = source
.map(|s| tag_url_for_provider(s, tag_name))
.unwrap_or_default();
let compare_url = match (source, previous_tag_name) {
(Some(s), Some(prev)) => compare_url_for_provider(s, prev, tag_name),
_ => tag_url.clone(),
};
let previous_version = previous_tag_name
.and_then(|t| parse_tag_prefix_and_version(t).map(|(_, v)| v.to_string()))
.unwrap_or_default();
Self {
id: id.to_string(),
version: version.to_string(),
previous_version,
date,
time,
datetime,
changes_count,
tag_url,
compare_url,
}
}
fn render(&self, template: &str) -> String {
let context = minijinja::context! {
id => &self.id,
version => &self.version,
previous_version => &self.previous_version,
date => &self.date,
time => &self.time,
datetime => &self.datetime,
changes_count => self.changes_count,
tag_url => &self.tag_url,
compare_url => &self.compare_url,
};
let jinja_value = minijinja::Value::from_serialize(&context);
render_jinja_template(template, &jinja_value).unwrap_or_else(|_| self.version.clone())
}
}
pub(crate) fn resolve_release_datetime() -> chrono::NaiveDateTime {
use chrono::NaiveDate;
use chrono::NaiveDateTime;
let Ok(env_date) = std::env::var("MONOCHANGE_RELEASE_DATE") else {
return chrono::Local::now().naive_local();
};
if let Ok(ndt) = NaiveDateTime::parse_from_str(&env_date, "%Y-%m-%dT%H:%M:%S") {
return ndt;
}
if let Ok(nd) = NaiveDate::parse_from_str(&env_date, "%Y-%m-%d") {
return nd.and_hms_opt(0, 0, 0).unwrap_or_default();
}
chrono::Local::now().naive_local()
}
pub(crate) fn effective_title_template<'a>(
specific: Option<&'a str>,
defaults: Option<&'a str>,
builtin: &'a str,
) -> &'a str {
specific.or(defaults).unwrap_or(builtin)
}
#[allow(clippy::match_same_arms)]
pub(crate) fn default_release_title_for_format(version_format: VersionFormat) -> &'static str {
match version_format {
VersionFormat::Primary => DEFAULT_RELEASE_TITLE_PRIMARY,
VersionFormat::Namespaced => DEFAULT_RELEASE_TITLE_NAMESPACED,
_ => DEFAULT_RELEASE_TITLE_PRIMARY,
}
}
#[allow(clippy::match_same_arms)]
pub(crate) fn default_changelog_version_title_for_format(
version_format: VersionFormat,
) -> &'static str {
match version_format {
VersionFormat::Primary => DEFAULT_CHANGELOG_VERSION_TITLE_PRIMARY,
VersionFormat::Namespaced => DEFAULT_CHANGELOG_VERSION_TITLE_NAMESPACED,
_ => DEFAULT_CHANGELOG_VERSION_TITLE_PRIMARY,
}
}
#[cfg(feature = "cargo")]
pub(crate) fn build_cargo_manifest_updates(
packages: &[PackageRecord],
plan: &ReleasePlan,
) -> MonochangeResult<Vec<FileUpdate>> {
use rayon::prelude::*;
let released_versions = plan
.decisions
.iter()
.filter(|decision| decision.recommended_bump.is_release())
.filter_map(|decision| {
decision
.planned_version
.as_ref()
.map(|version| (decision.package_id.clone(), version.to_string()))
})
.collect::<BTreeMap<_, _>>();
let released_versions_by_name = packages
.iter()
.filter_map(|package| {
released_versions
.get(&package.id)
.map(|version| (package.name.clone(), version.clone()))
})
.collect::<BTreeMap<_, _>>();
if released_versions_by_name.is_empty() {
return Ok(Vec::new());
}
let mut updated_documents = packages
.iter()
.filter(|package| package.ecosystem == Ecosystem::Cargo)
.par_bridge()
.filter_map(|package| {
let should_update_manifest = released_versions.contains_key(&package.id)
|| package
.declared_dependencies
.iter()
.any(|dependency| released_versions_by_name.contains_key(&dependency.name));
should_update_manifest.then_some(package)
})
.map(|package| {
let contents = fs::read_to_string(&package.manifest_path).map_err(|error| {
MonochangeError::Io(format!(
"failed to read {}: {error}",
package.manifest_path.display()
))
})?;
let updated = monochange_cargo::update_versioned_file_text(
&contents,
monochange_cargo::CargoVersionedFileKind::Manifest,
&["dependencies", "dev-dependencies", "build-dependencies"],
released_versions.get(&package.id).map(String::as_str),
None,
&released_versions_by_name,
&BTreeMap::new(),
)
.map_err(|error| {
MonochangeError::Config(format!(
"failed to parse {}: {error}",
package.manifest_path.display()
))
})?;
Ok((package.manifest_path.clone(), updated))
})
.collect::<MonochangeResult<BTreeMap<_, _>>>()?;
for workspace_root in packages
.iter()
.filter(|package| package.ecosystem == Ecosystem::Cargo)
.filter(|package| released_versions.contains_key(&package.id))
.map(|package| package.workspace_root.clone())
.collect::<BTreeSet<_>>()
{
let workspace_version = packages
.iter()
.filter(|package| {
package.ecosystem == Ecosystem::Cargo
&& package.workspace_root == workspace_root
&& released_versions.contains_key(&package.id)
})
.filter_map(|package| released_versions.get(&package.id))
.cloned()
.collect::<BTreeSet<_>>();
let Some(shared_workspace_version) = workspace_version.first().cloned() else {
continue;
};
if workspace_version.len() != 1 {
continue;
}
let workspace_manifest = workspace_root.join("Cargo.toml");
if !workspace_manifest.exists() {
continue;
}
let contents = if let Some(document) = updated_documents.remove(&workspace_manifest) {
document
} else {
fs::read_to_string(&workspace_manifest).map_err(|error| {
MonochangeError::Io(format!(
"failed to read {}: {error}",
workspace_manifest.display()
))
})?
};
let updated = monochange_cargo::update_versioned_file_text(
&contents,
monochange_cargo::CargoVersionedFileKind::Manifest,
&["dependencies", "dev-dependencies", "build-dependencies"],
None,
Some(shared_workspace_version.as_str()),
&released_versions_by_name,
&BTreeMap::new(),
)
.map_err(|error| {
MonochangeError::Config(format!(
"failed to parse {}: {error}",
workspace_manifest.display()
))
})?;
updated_documents.insert(workspace_manifest, updated);
}
Ok(updated_documents
.into_iter()
.map(|(path, document)| {
FileUpdate {
path,
content: document.into_bytes(),
}
})
.collect())
}
#[cfg(feature = "npm")]
pub(crate) fn build_npm_manifest_updates(
packages: &[PackageRecord],
plan: &ReleasePlan,
) -> MonochangeResult<Vec<FileUpdate>> {
use rayon::prelude::*;
let released_versions = released_versions_by_record_id(plan);
packages
.iter()
.filter(|package| package.ecosystem == Ecosystem::Npm)
.par_bridge()
.filter_map(|package| {
released_versions
.get(&package.id)
.map(|version| (package, version))
})
.map(|(package, version)| {
let contents = fs::read_to_string(&package.manifest_path).map_err(|error| {
MonochangeError::Io(format!(
"failed to read {}: {error}",
package.manifest_path.display()
))
})?;
let rendered = monochange_core::update_json_manifest_text(
&contents,
Some(version),
&[],
&BTreeMap::new(),
)
.map_err(|error| {
MonochangeError::Config(format!(
"failed to parse {}: {error}",
package.manifest_path.display()
))
})?;
Ok(FileUpdate {
path: package.manifest_path.clone(),
content: rendered.into_bytes(),
})
})
.collect()
}
#[cfg(feature = "deno")]
pub(crate) fn build_deno_manifest_updates(
packages: &[PackageRecord],
plan: &ReleasePlan,
) -> MonochangeResult<Vec<FileUpdate>> {
use rayon::prelude::*;
let released_versions = released_versions_by_record_id(plan);
packages
.iter()
.filter(|package| package.ecosystem == Ecosystem::Deno)
.par_bridge()
.filter_map(|package| {
released_versions
.get(&package.id)
.map(|version| (package, version))
})
.map(|(package, version)| {
let contents = fs::read_to_string(&package.manifest_path).map_err(|error| {
MonochangeError::Io(format!(
"failed to read {}: {error}",
package.manifest_path.display()
))
})?;
let rendered = monochange_core::update_json_manifest_text(
&contents,
Some(version),
&[],
&BTreeMap::new(),
)
.map_err(|error| {
MonochangeError::Config(format!(
"failed to parse {}: {error}",
package.manifest_path.display()
))
})?;
Ok(FileUpdate {
path: package.manifest_path.clone(),
content: rendered.into_bytes(),
})
})
.collect()
}
#[cfg(feature = "dart")]
pub(crate) fn build_dart_manifest_updates(
packages: &[PackageRecord],
plan: &ReleasePlan,
) -> MonochangeResult<Vec<FileUpdate>> {
use rayon::prelude::*;
let released_versions = released_versions_by_record_id(plan);
packages
.iter()
.filter(|package| {
package.ecosystem == Ecosystem::Dart || package.ecosystem == Ecosystem::Flutter
})
.par_bridge()
.filter_map(|package| {
released_versions
.get(&package.id)
.map(|version| (package, version))
})
.map(|(package, version)| {
let contents = fs::read_to_string(&package.manifest_path).map_err(|error| {
MonochangeError::Io(format!(
"failed to read {}: {error}",
package.manifest_path.display()
))
})?;
let rendered = monochange_dart::update_manifest_text(
&contents,
Some(version),
&[],
&BTreeMap::new(),
)
.map_err(|error| {
MonochangeError::Config(format!(
"failed to parse {}: {error}",
package.manifest_path.display()
))
})?;
Ok(FileUpdate {
path: package.manifest_path.clone(),
content: rendered.into_bytes(),
})
})
.collect()
}
#[must_use = "the file update result must be checked"]
pub(crate) fn apply_file_updates(updates: &[FileUpdate]) -> MonochangeResult<()> {
for update in updates {
if let Some(parent) = update.path.parent() {
fs::create_dir_all(parent).map_err(|error| {
MonochangeError::Io(format!("failed to create {}: {error}", parent.display()))
})?;
}
atomic_write(&update.path, &update.content)?;
}
Ok(())
}
fn atomic_write(path: &Path, content: &[u8]) -> MonochangeResult<()> {
let parent = path.parent().unwrap_or(path);
let original_permissions = fs::metadata(path).ok().map(|meta| meta.permissions());
let mut temp = tempfile::NamedTempFile::new_in(parent).map_err(|error| {
MonochangeError::Io(format!(
"failed to create temp file in {}: {error}",
parent.display()
))
})?;
write_temp_file(&mut temp, path, content)?;
persist_temp_file(temp, path)?;
if let Some(permissions) = original_permissions {
fs::set_permissions(path, permissions).map_err(|error| {
MonochangeError::Io(format!(
"failed to restore permissions on {}: {error}",
path.display()
))
})?;
}
Ok(())
}
fn write_temp_file(
writer: &mut impl std::io::Write,
path: &Path,
content: &[u8],
) -> MonochangeResult<()> {
std::io::Write::write_all(writer, content).map_err(|error| temp_file_write_error(path, &error))
}
fn persist_temp_file(temp: tempfile::NamedTempFile, path: &Path) -> MonochangeResult<()> {
temp.persist(path)
.map(|_| ())
.map_err(|error| temp_file_persist_error(path, &error))
}
fn temp_file_write_error(path: &Path, error: &std::io::Error) -> MonochangeError {
MonochangeError::Io(format!(
"failed to write temp file for {}: {error}",
path.display()
))
}
fn temp_file_persist_error(path: &Path, error: &tempfile::PersistError) -> MonochangeError {
MonochangeError::Io(format!(
"failed to rename temp file to {}: {error}",
path.display()
))
}
#[tracing::instrument(skip_all)]
pub(crate) fn build_file_diff_previews(
root: &Path,
updates: &[FileUpdate],
) -> MonochangeResult<Vec<PreparedFileDiff>> {
let colorize_diffs = diff_output_colors_enabled();
#[cfg(test)]
if FORCE_BUILD_FILE_DIFF_PREVIEWS_ERROR.with(Cell::get) {
return Err(MonochangeError::Io(
"forced build_file_diff_previews test error".to_string(),
));
}
let mut previews = updates
.iter()
.filter_map(|update| {
let path = root_relative(root, &update.path);
let before = match fs::read(&update.path) {
Ok(content) => content,
Err(error)
if matches!(
error.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
) =>
{
Vec::new()
}
Err(error) => {
return Some(Err(MonochangeError::Io(format!(
"failed to read {}: {error}",
update.path.display()
))));
}
};
(before != update.content).then(|| {
let diff = render_unified_file_diff(&path, &before, &update.content);
Ok(PreparedFileDiff {
path: path.clone(),
display_diff: render_display_file_diff(&diff, colorize_diffs),
diff,
})
})
})
.collect::<MonochangeResult<Vec<_>>>()?;
previews.sort_by(|left, right| left.path.cmp(&right.path));
Ok(previews)
}
fn render_unified_file_diff(path: &Path, before: &[u8], after: &[u8]) -> String {
let before_text = String::from_utf8_lossy(before);
let after_text = String::from_utf8_lossy(after);
let context_radius = before_text.lines().count().max(after_text.lines().count());
let diff = TextDiff::from_lines(before_text.as_ref(), after_text.as_ref());
let mut unified = diff.unified_diff();
unified.context_radius(context_radius).header(
&format!("a/{}", path.display()),
&format!("b/{}", path.display()),
);
unified.to_string().trim_end_matches('\n').to_string()
}
fn render_display_file_diff(diff: &str, colorize: bool) -> String {
if !colorize {
return diff.to_string();
}
colorize_diff_output(diff)
}
fn diff_output_colors_enabled() -> bool {
diff_output_supports_color(std::io::stdout().is_terminal())
}
pub(crate) fn diff_output_supports_color(stdout_is_terminal: bool) -> bool {
if std::env::var_os("NO_COLOR").is_some() {
return false;
}
if std::env::var("CLICOLOR_FORCE")
.ok()
.is_some_and(|value| value != "0")
{
return true;
}
if std::env::var("CLICOLOR")
.ok()
.is_some_and(|value| value == "0")
{
return false;
}
stdout_is_terminal
}
pub(crate) fn colorize_diff_output(diff: &str) -> String {
let mut output = String::with_capacity(diff.len());
for (index, line) in diff.lines().enumerate() {
if index > 0 {
output.push('\n');
}
push_colorized_diff_line(&mut output, line);
}
output
}
fn push_colorized_diff_line(output: &mut String, line: &str) {
if line.starts_with("--- ") || line.starts_with("+++ ") {
push_ansi_style(output, line, "1;36");
} else if line.starts_with("@@ ") {
push_ansi_style(output, line, "36");
} else if line.starts_with('+') && !line.starts_with("+++") {
push_ansi_style(output, line, "32");
} else if line.starts_with('-') && !line.starts_with("---") {
push_ansi_style(output, line, "31");
} else if line == r"\ No newline at end of file" {
push_ansi_style(output, line, "33");
} else {
output.push_str(line);
}
}
fn push_ansi_style(output: &mut String, line: &str, style: &str) {
output.push_str("\u{1b}[");
output.push_str(style);
output.push('m');
output.push_str(line);
output.push_str("\u{1b}[0m");
}
pub(crate) fn shared_release_version(plan: &ReleasePlan) -> Option<String> {
let versions = plan
.decisions
.iter()
.filter(|decision| decision.recommended_bump.is_release())
.filter_map(|decision| decision.planned_version.as_ref().map(ToString::to_string))
.collect::<BTreeSet<_>>();
if versions.len() == 1 {
versions.first().cloned()
} else {
None
}
}
pub(crate) fn shared_group_version(plan: &ReleasePlan) -> Option<String> {
let versions = plan
.groups
.iter()
.filter(|group| group.recommended_bump.is_release())
.filter_map(|group| group.planned_version.as_ref().map(ToString::to_string))
.collect::<BTreeSet<_>>();
if versions.len() == 1 {
versions.first().cloned()
} else {
None
}
}
pub(crate) fn root_relative(root: &Path, path: &Path) -> PathBuf {
let relative = relative_to_root(root, path).unwrap_or_else(|| path.to_path_buf());
if relative.as_os_str().is_empty() {
PathBuf::from(".")
} else {
relative
}
}
pub(crate) fn render_discovery_report(
report: &DiscoveryReport,
format: OutputFormat,
) -> MonochangeResult<String> {
match format {
OutputFormat::Json => {
serde_json::to_string_pretty(&json_discovery_report(report))
.map_err(|error| MonochangeError::Discovery(error.to_string()))
}
OutputFormat::Markdown | OutputFormat::Text => Ok(text_discovery_report(report)),
}
}
pub(crate) fn build_release_manifest(
cli_command: &CliCommandDefinition,
prepared_release: &PreparedRelease,
_command_logs: &[String],
) -> ReleaseManifest {
ReleaseManifest {
command: cli_command.name.clone(),
dry_run: prepared_release.dry_run,
version: prepared_release.version.clone(),
group_version: prepared_release.group_version.clone(),
release_targets: prepared_release
.release_targets
.iter()
.map(|target| {
ReleaseManifestTarget {
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(),
released_packages: prepared_release.released_packages.clone(),
changed_files: prepared_release.changed_files.clone(),
changelogs: prepared_release
.changelogs
.iter()
.map(|changelog| {
ReleaseManifestChangelog {
owner_id: changelog.owner_id.clone(),
owner_kind: changelog.owner_kind,
path: changelog.path.clone(),
format: changelog.format,
notes: changelog.notes.clone(),
rendered: changelog.rendered.clone(),
}
})
.collect(),
package_publications: prepared_release.package_publications.clone(),
changesets: prepared_release.changesets.clone(),
deleted_changesets: prepared_release.deleted_changesets.clone(),
plan: ReleaseManifestPlan {
workspace_root: PathBuf::from("."),
decisions: prepared_release
.plan
.decisions
.iter()
.map(|decision| {
ReleaseManifestPlanDecision {
package: decision.package_id.clone(),
bump: decision.recommended_bump,
trigger: decision.trigger_type.clone(),
planned_version: decision.planned_version.as_ref().map(ToString::to_string),
reasons: decision.reasons.clone(),
upstream_sources: decision.upstream_sources.clone(),
}
})
.collect(),
groups: prepared_release
.plan
.groups
.iter()
.map(|group| {
ReleaseManifestPlanGroup {
id: group.group_id.clone(),
planned_version: group.planned_version.as_ref().map(ToString::to_string),
members: group.members.clone(),
bump: group.recommended_bump,
}
})
.collect(),
warnings: prepared_release.plan.warnings.clone(),
unresolved_items: prepared_release.plan.unresolved_items.clone(),
compatibility_evidence: prepared_release
.plan
.compatibility_evidence
.iter()
.map(|assessment| {
ReleaseManifestCompatibilityEvidence {
package: assessment.package_id.clone(),
provider: assessment.provider_id.clone(),
severity: assessment.severity,
summary: assessment.summary.clone(),
confidence: assessment.confidence.clone(),
evidence_location: assessment.evidence_location.clone(),
}
})
.collect(),
},
}
}
pub(crate) fn build_release_manifest_from_record(record: &ReleaseRecord) -> ReleaseManifest {
ReleaseManifest {
command: record.command.clone(),
dry_run: false,
version: record.version.clone(),
group_version: None,
release_targets: record
.release_targets
.iter()
.map(|target| {
ReleaseManifestTarget {
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: String::new(),
rendered_changelog_title: String::new(),
}
})
.collect(),
released_packages: record.released_packages.clone(),
changed_files: record.changed_files.clone(),
changelogs: if record.changelogs.is_empty() {
record
.updated_changelogs
.iter()
.map(|path| {
ReleaseManifestChangelog {
owner_id: String::new(),
owner_kind: ReleaseOwnerKind::Group,
path: path.clone(),
format: ChangelogFormat::default(),
notes: ReleaseNotesDocument {
title: String::new(),
summary: Vec::new(),
sections: Vec::new(),
},
rendered: String::new(),
}
})
.collect()
} else {
record.changelogs.clone()
},
package_publications: record.package_publications.clone(),
changesets: record.changesets.clone(),
deleted_changesets: record.deleted_changesets.clone(),
plan: ReleaseManifestPlan {
workspace_root: PathBuf::from("."),
decisions: Vec::new(),
groups: Vec::new(),
warnings: Vec::new(),
unresolved_items: Vec::new(),
compatibility_evidence: Vec::new(),
},
}
}
fn release_record_versions(release_targets: &[ReleaseManifestTarget]) -> BTreeMap<String, String> {
release_targets
.iter()
.map(|target| (target.id.clone(), target.version.clone()))
.collect()
}
pub(crate) fn build_release_record(
source: Option<&SourceConfiguration>,
manifest: &ReleaseManifest,
) -> ReleaseRecord {
ReleaseRecord {
schema_version: monochange_core::RELEASE_RECORD_SCHEMA_VERSION.to_string(),
kind: monochange_core::RELEASE_RECORD_KIND.to_string(),
created_at: resolve_release_datetime()
.and_utc()
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
command: manifest.command.clone(),
version: manifest.version.clone(),
versions: release_record_versions(&manifest.release_targets),
release_targets: manifest
.release_targets
.iter()
.map(|target| {
ReleaseRecordTarget {
id: target.id.clone(),
kind: target.kind,
version: target.version.clone(),
version_format: target.version_format,
tag: target.tag,
release: target.release,
tag_name: target.tag_name.clone(),
members: target.members.clone(),
}
})
.collect(),
released_packages: manifest.released_packages.clone(),
changed_files: manifest.changed_files.clone(),
package_publications: manifest.package_publications.clone(),
updated_changelogs: manifest
.changelogs
.iter()
.map(|changelog| changelog.path.clone())
.collect(),
changelogs: manifest.changelogs.clone(),
deleted_changesets: manifest.deleted_changesets.clone(),
changesets: manifest.changesets.clone(),
provider: source.map(|source| {
ReleaseRecordProvider {
kind: source.provider,
owner: source.owner.clone(),
repo: source.repo.clone(),
host: source.host.clone(),
}
}),
}
}
pub(crate) fn build_release_commit_message(
source: Option<&SourceConfiguration>,
manifest: &ReleaseManifest,
) -> CommitMessage {
CommitMessage {
subject: source.map_or_else(
|| monochange_core::ProviderMergeRequestSettings::default().title,
|source| source.pull_requests.title.clone(),
),
body: Some(render_release_commit_body(source, manifest)),
}
}
pub(crate) fn render_release_commit_body(
_source: Option<&SourceConfiguration>,
manifest: &ReleaseManifest,
) -> String {
let mut lines = vec!["Prepare release.".to_string()];
if !manifest.release_targets.is_empty() {
lines.push(String::new());
lines.push(format!(
"- release targets: {}",
manifest
.release_targets
.iter()
.map(|target| format!("{} ({})", target.id, target.version))
.collect::<Vec<_>>()
.join(", ")
));
}
if !manifest.released_packages.is_empty() {
lines.push(format!(
"- released packages: {}",
manifest.released_packages.join(", ")
));
}
if !manifest.changelogs.is_empty() {
lines.push(format!(
"- updated changelogs: {}",
manifest
.changelogs
.iter()
.map(|changelog| changelog.path.display().to_string())
.collect::<Vec<_>>()
.join(", ")
));
}
if !manifest.deleted_changesets.is_empty() {
lines.push(format!(
"- deleted changesets: {}",
manifest
.deleted_changesets
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
.join(", ")
));
}
lines.join("\n")
}
#[must_use = "the manifest render result must be checked"]
pub(crate) fn render_release_manifest_json(manifest: &ReleaseManifest) -> MonochangeResult<String> {
serde_json::to_string_pretty(manifest)
.map_err(|error| MonochangeError::Discovery(error.to_string()))
}
pub(crate) fn build_source_release_requests(
source: &SourceConfiguration,
manifest: &ReleaseManifest,
) -> Vec<SourceReleaseRequest> {
match source.provider {
#[cfg(feature = "github")]
SourceProvider::GitHub => github_provider::build_release_requests(source, manifest),
#[cfg(feature = "gitlab")]
SourceProvider::GitLab => gitlab_provider::build_release_requests(source, manifest),
#[cfg(feature = "gitea")]
SourceProvider::Gitea => gitea_provider::build_release_requests(source, manifest),
#[cfg(feature = "forgejo")]
SourceProvider::Forgejo => forgejo_provider::build_release_requests(source, manifest),
#[cfg(not(any(
feature = "github",
feature = "gitlab",
feature = "gitea",
feature = "forgejo"
)))]
_ => Vec::new(),
}
}
pub(crate) fn build_source_change_request(
source: &SourceConfiguration,
manifest: &ReleaseManifest,
) -> SourceChangeRequest {
let mut request = match source.provider {
#[cfg(feature = "github")]
SourceProvider::GitHub => github_provider::build_release_pull_request_request(source, manifest),
#[cfg(feature = "gitlab")]
SourceProvider::GitLab => gitlab_provider::build_release_pull_request_request(source, manifest),
#[cfg(feature = "gitea")]
SourceProvider::Gitea => gitea_provider::build_release_pull_request_request(source, manifest),
#[cfg(feature = "forgejo")]
SourceProvider::Forgejo => forgejo_provider::build_release_pull_request_request(source, manifest),
#[cfg(not(any(
feature = "github",
feature = "gitlab",
feature = "gitea",
feature = "forgejo"
)))]
_ => {
unreachable!(
"a hosting provider feature must be enabled to build source change requests"
)
}
};
request.commit_message = build_release_commit_message(Some(source), manifest);
request
}
pub(crate) async fn publish_source_release_requests(
source: &SourceConfiguration,
requests: &[SourceReleaseRequest],
) -> MonochangeResult<Vec<SourceReleaseOutcome>> {
match source.provider {
#[cfg(feature = "github")]
SourceProvider::GitHub => github_provider::publish_release_requests(source, requests).await,
#[cfg(feature = "gitlab")]
SourceProvider::GitLab => gitlab_provider::publish_release_requests(source, requests).await,
#[cfg(feature = "gitea")]
SourceProvider::Gitea => gitea_provider::publish_release_requests(source, requests).await,
#[cfg(feature = "forgejo")]
SourceProvider::Forgejo => forgejo_provider::publish_release_requests(source, requests).await,
#[cfg(not(any(
feature = "github",
feature = "gitlab",
feature = "gitea",
feature = "forgejo"
)))]
_ => Ok(Vec::new()),
}
}
pub(crate) async fn publish_source_change_request(
source: &SourceConfiguration,
root: &Path,
request: &SourceChangeRequest,
tracked_paths: &[PathBuf],
no_verify: bool,
stage_all: bool,
) -> MonochangeResult<SourceChangeRequestOutcome> {
match source.provider {
#[cfg(feature = "github")]
SourceProvider::GitHub => {
github_provider::publish_release_pull_request(
source,
root,
request,
tracked_paths,
no_verify,
stage_all,
)
.await
}
#[cfg(feature = "gitlab")]
SourceProvider::GitLab => {
gitlab_provider::publish_release_pull_request(
source,
root,
request,
tracked_paths,
no_verify,
stage_all,
)
.await
}
#[cfg(feature = "gitea")]
SourceProvider::Gitea => {
gitea_provider::publish_release_pull_request(
source,
root,
request,
tracked_paths,
no_verify,
stage_all,
)
.await
}
#[cfg(feature = "forgejo")]
SourceProvider::Forgejo => {
forgejo_provider::publish_release_pull_request(
source,
root,
request,
tracked_paths,
no_verify,
stage_all,
)
.await
}
#[cfg(not(any(
feature = "github",
feature = "gitlab",
feature = "gitea",
feature = "forgejo"
)))]
_ => {
Err(MonochangeError::Config(
"no hosting provider feature enabled".to_string(),
))
}
}
}
pub(crate) fn format_source_operation(operation: &SourceReleaseOperation) -> &'static str {
match operation {
SourceReleaseOperation::Created => "created",
SourceReleaseOperation::Updated => "updated",
}
}
pub(crate) fn format_change_request_operation(
operation: &SourceChangeRequestOperation,
) -> &'static str {
match operation {
SourceChangeRequestOperation::Created => "created",
SourceChangeRequestOperation::Updated => "updated",
SourceChangeRequestOperation::Skipped => "skipped",
}
}
pub(crate) struct ReleaseCliJsonSections<'a> {
pub releases: &'a [SourceReleaseRequest],
pub release_request: Option<&'a SourceChangeRequest>,
pub issue_comments: &'a [HostedIssueCommentPlan],
pub release_commit: Option<&'a CommitReleaseReport>,
pub package_publish: Option<&'a package_publish::PackagePublishReport>,
pub publish_rate_limits: Option<&'a monochange_core::PublishRateLimitReport>,
pub file_diffs: &'a [PreparedFileDiff],
}
pub(crate) fn render_release_cli_command_json(
manifest: &ReleaseManifest,
sections: &ReleaseCliJsonSections,
) -> MonochangeResult<String> {
if sections.releases.is_empty()
&& sections.release_request.is_none()
&& sections.issue_comments.is_empty()
&& sections.release_commit.is_none()
&& sections.package_publish.is_none()
&& sections.publish_rate_limits.is_none()
&& sections.file_diffs.is_empty()
{
return render_release_manifest_json(manifest);
}
let mut value = json!({
"manifest": manifest,
"releaseCommit": sections.release_commit,
"releases": sections.releases,
"releaseRequest": sections.release_request,
"issueComments": sections.issue_comments,
"packagePublish": sections.package_publish,
"publishRateLimits": sections.publish_rate_limits,
});
if !sections.file_diffs.is_empty() {
value
.as_object_mut()
.unwrap_or_else(|| panic!("release json wrapper must stay object"))
.insert(
"fileDiffs".to_string(),
serde_json::to_value(sections.file_diffs).unwrap_or_default(),
);
}
serde_json::to_string_pretty(&value)
.map_err(|error| MonochangeError::Discovery(error.to_string()))
}
pub(crate) fn write_release_record_file(
root: &Path,
source: Option<&SourceConfiguration>,
manifest: &ReleaseManifest,
) -> MonochangeResult<PathBuf> {
let paths = ReleasePaths::from_manifest(root, manifest);
if paths.absolute.is_file() {
add_to_dedup_index(root, &paths.hash).ok();
return Ok(paths.absolute);
}
let record = build_release_record(source, manifest);
deduplicate_overlapping_release_records(
root,
&record.release_targets,
paths.absolute.parent().unwrap_or(root),
)?;
let json = serde_json::to_string_pretty(&record).unwrap_or_default();
fs::create_dir_all(paths.absolute.parent().unwrap_or(root))
.map_err(|error| MonochangeError::Io(format!("create release record dir: {error}")))?;
fs::write(&paths.absolute, json)
.map_err(|error| MonochangeError::Io(format!("write release record: {error}")))?;
add_to_dedup_index(root, &paths.hash)?;
Ok(paths.absolute)
}
fn compare_json_strings(json1: &str, json2: &str) -> bool {
if json1 == json2 {
return true;
}
let parsed1: Result<serde_json::Value, _> = serde_json::from_str(json1);
let parsed2: Result<serde_json::Value, _> = serde_json::from_str(json2);
match (parsed1, parsed2) {
(Ok(v1), Ok(v2)) => v1 == v2,
_ => false,
}
}
pub(crate) fn validate_release_record_file(
root: &Path,
source: Option<&SourceConfiguration>,
manifest: &ReleaseManifest,
update_release_json: bool,
) -> MonochangeResult<PathBuf> {
let paths = ReleasePaths::from_manifest(root, manifest);
if paths.absolute.is_file() {
match fs::read_to_string(&paths.absolute) {
Ok(existing_json) => {
if let Ok(existing_value) =
serde_json::from_str::<serde_json::Value>(&existing_json)
&& let Some(existing_targets) = existing_value
.get("releaseTargets")
.and_then(|v| v.as_array())
{
let manifest_targets = &manifest.release_targets;
if existing_targets.len() == manifest_targets.len() {
let all_match = manifest_targets.iter().all(|mt| {
existing_targets.iter().any(|et| {
let Some(id) = et.get("id").and_then(|v| v.as_str()) else {
return false;
};
let Some(kind) = et.get("kind").and_then(|v| v.as_str()) else {
return false;
};
let Some(version) = et.get("version").and_then(|v| v.as_str())
else {
return false;
};
mt.id == id && mt.kind.as_str() == kind && mt.version == version
})
});
if all_match {
add_to_dedup_index(root, &paths.hash).ok();
return Ok(paths.absolute);
}
}
}
}
Err(error) => {
return Err(MonochangeError::Io(format!("read release record: {error}")));
}
}
}
let record = build_release_record(source, manifest);
deduplicate_overlapping_release_records(
root,
&record.release_targets,
paths.absolute.parent().unwrap_or(root),
)?;
let json = serde_json::to_string_pretty(&record).unwrap_or_default();
if paths.absolute.is_file() {
let existing = fs::read_to_string(&paths.absolute)
.map_err(|error| MonochangeError::Io(format!("read release record: {error}")))?;
if !compare_json_strings(&existing, &json) {
if update_release_json {
fs::write(&paths.absolute, json).map_err(|error| {
MonochangeError::Io(format!("update release record: {error}"))
})?;
} else {
return Err(MonochangeError::Io(format!(
"release record at {} does not match expected content — the file has been modified since it was prepared. Set `update_release_json = true` on the CommitRelease step to allow overwriting.",
paths.absolute.display()
)));
}
}
} else if update_release_json {
fs::create_dir_all(paths.absolute.parent().unwrap_or(root))
.map_err(|error| MonochangeError::Io(format!("create release record dir: {error}")))?;
fs::write(&paths.absolute, json)
.map_err(|error| MonochangeError::Io(format!("write release record: {error}")))?;
} else {
return Err(MonochangeError::Io(format!(
"no release record found at {} — was it removed by deduplication or never written?",
paths.absolute.display()
)));
}
Ok(paths.absolute)
}
#[allow(dead_code)]
pub(crate) struct ReleasePaths {
pub hash: String,
pub relative: PathBuf,
pub absolute: PathBuf,
}
impl ReleasePaths {
#[allow(dead_code)]
pub fn from_record(root: &Path, record: &ReleaseRecord) -> Self {
let hash = release_targets_hash(&record.release_targets);
let relative = PathBuf::from(".monochange/releases")
.join(&hash)
.join("release.json");
let absolute = root.join(&relative);
Self {
hash,
relative,
absolute,
}
}
pub fn from_manifest(root: &Path, manifest: &ReleaseManifest) -> Self {
let hash = release_targets_hash(&manifest.release_targets);
let relative = PathBuf::from(".monochange/releases")
.join(&hash)
.join("release.json");
let absolute = root.join(&relative);
Self {
hash,
relative,
absolute,
}
}
}
fn release_targets_hash<T: ReleaseTargetIdentity>(release_targets: &[T]) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::Hasher;
let mut hasher = DefaultHasher::new();
let mut sorted: Vec<&T> = release_targets.iter().collect();
sorted.sort_by(|a, b| {
a.id()
.cmp(b.id())
.then_with(|| a.kind().as_str().cmp(b.kind().as_str()))
.then_with(|| a.version().cmp(b.version()))
});
for target in sorted {
hasher.write(target.id().as_bytes());
hasher.write(target.kind().as_str().as_bytes());
hasher.write(target.version().as_bytes());
}
format!("{:016x}", hasher.finish())
}
trait ReleaseTargetIdentity {
fn id(&self) -> &str;
fn kind(&self) -> ReleaseOwnerKind;
fn version(&self) -> &str;
}
impl ReleaseTargetIdentity for ReleaseManifestTarget {
fn id(&self) -> &str {
&self.id
}
fn kind(&self) -> ReleaseOwnerKind {
self.kind
}
fn version(&self) -> &str {
&self.version
}
}
impl ReleaseTargetIdentity for ReleaseRecordTarget {
fn id(&self) -> &str {
&self.id
}
fn kind(&self) -> ReleaseOwnerKind {
self.kind
}
fn version(&self) -> &str {
&self.version
}
}
fn deduplicate_overlapping_release_records(
root: &Path,
release_targets: &[ReleaseRecordTarget],
current_record_dir: &Path,
) -> MonochangeResult<()> {
let hash = release_targets_hash(release_targets);
let already_deduped = DEDUPLICATED_CACHE
.with(|cache| cache.borrow().contains(&(root.to_path_buf(), hash.clone())));
if already_deduped {
return Ok(());
}
let persistent_index = load_dedup_index(root);
if persistent_index.contains(&hash) {
DEDUPLICATED_CACHE.with(|cache| {
cache
.borrow_mut()
.insert((root.to_path_buf(), hash.clone()));
});
return Ok(());
}
let new_tags: std::collections::HashSet<(&str, &str)> = release_targets
.iter()
.map(|target| (target.id.as_str(), target.version.as_str()))
.collect();
let releases_dir = root.join(".monochange/releases");
if !releases_dir.is_dir() {
return Ok(());
}
for entry in fs::read_dir(&releases_dir)
.map_err(|error| MonochangeError::Io(format!("read releases dir: {error}")))?
{
let entry =
entry.map_err(|error| MonochangeError::Io(format!("read dir entry: {error}")))?;
let path = entry.path();
if path == current_record_dir {
continue;
}
if !path.is_dir() {
continue;
}
let record_file = path.join("release.json");
if !record_file.is_file() {
continue;
}
let Ok(content) = fs::read_to_string(&record_file) else {
continue;
};
let Ok(existing) = serde_json::from_str::<ReleaseRecord>(&content) else {
continue;
};
let has_overlap = existing
.release_targets
.iter()
.any(|t| new_tags.contains(&(t.id.as_str(), t.version.as_str())));
if has_overlap {
let hash_to_remove = release_targets_hash(&existing.release_targets);
fs::remove_dir_all(&path).map_err(|error| {
MonochangeError::Io(format!("remove stale release record dir: {error}"))
})?;
remove_from_dedup_index(root, &hash_to_remove).ok();
}
}
DEDUPLICATED_CACHE.with(|cache| {
cache
.borrow_mut()
.insert((root.to_path_buf(), hash.clone()));
});
add_to_dedup_index(root, &hash)?;
Ok(())
}
pub(crate) async fn commit_release(
root: &Path,
context: &CliContext,
source: Option<&SourceConfiguration>,
manifest: &ReleaseManifest,
no_verify: bool,
update_release_json: bool,
stage_all: bool,
) -> MonochangeResult<CommitReleaseReport> {
let tracked_paths = tracked_release_pull_request_paths(context, manifest);
let message = build_release_commit_message(source, manifest);
let release_record_path =
validate_release_record_file(root, source, manifest, update_release_json)?;
let mut tracked_paths = tracked_paths;
tracked_paths.push(release_record_path);
if !context.dry_run {
if stage_all {
git_stage_all(root).await?;
} else {
git_stage_paths(root, &tracked_paths).await?;
}
git_commit_paths(root, &message, no_verify).await?;
}
Ok(CommitReleaseReport {
subject: message.subject,
body: message.body.unwrap_or_default(),
commit: if context.dry_run {
None
} else {
Some(git_head_commit(root).await?)
},
tracked_paths,
dry_run: context.dry_run,
status: if context.dry_run {
"dry_run".to_string()
} else {
"completed".to_string()
},
})
}
pub(crate) fn tracked_release_pull_request_paths(
context: &CliContext,
manifest: &ReleaseManifest,
) -> Vec<PathBuf> {
let mut tracked_paths = manifest.changed_files.clone();
tracked_paths.extend(manifest.deleted_changesets.clone());
if let Some(path) = &context.release_manifest_path {
tracked_paths.push(path.clone());
}
tracked_paths.sort();
tracked_paths.dedup();
tracked_paths
}
pub(crate) fn json_discovery_report(report: &DiscoveryReport) -> serde_json::Value {
json!({
"workspaceRoot": PathBuf::from("."),
"packages": report.packages.iter().map(|package| {
json!({
"id": package.id,
"name": package.name,
"ecosystem": package.ecosystem.as_str(),
"manifestPath": root_relative(&report.workspace_root, &package.manifest_path),
"workspaceRoot": PathBuf::from("."),
"version": package.current_version.as_ref().map(ToString::to_string),
"versionGroup": package.version_group_id,
"publishState": format_publish_state(package.publish_state),
})
}).collect::<Vec<_>>(),
"dependencies": report.dependencies.iter().map(|edge| {
json!({
"from": edge.from_package_id,
"to": edge.to_package_id,
"kind": edge.dependency_kind.to_string(),
"direct": edge.is_direct,
})
}).collect::<Vec<_>>(),
"versionGroups": report.version_groups.iter().map(|group| {
json!({
"id": group.group_id,
"members": group.members,
"mismatchDetected": group.mismatch_detected,
})
}).collect::<Vec<_>>(),
"warnings": report.warnings,
})
}
pub(crate) fn text_discovery_report(report: &DiscoveryReport) -> String {
let mut counts = BTreeMap::<Ecosystem, usize>::new();
for package in &report.packages {
*counts.entry(package.ecosystem).or_default() += 1;
}
let mut lines = vec![format!(
"Workspace discovery for {}",
report.workspace_root.display()
)];
lines.push(format!("Packages: {}", report.packages.len()));
for (ecosystem, count) in counts {
lines.push(format!("- {ecosystem}: {count}"));
}
lines.push(format!("Dependencies: {}", report.dependencies.len()));
if !report.version_groups.is_empty() {
lines.push("Version groups:".to_string());
for group in &report.version_groups {
lines.push(format!("- {} ({})", group.group_id, group.members.len()));
}
}
if !report.warnings.is_empty() {
lines.push("Warnings:".to_string());
for warning in &report.warnings {
lines.push(format!("- {warning}"));
}
}
lines.join("\n")
}
#[cfg(test)]
#[path = "__tests__/release_artifacts_tests.rs"]
mod tests;