diff --git a/BREAKINGCHANGES.md b/BREAKINGCHANGES.md new file mode 100644 index 00000000..e69de29b diff --git a/BREAKINGCHNAGES_dev.md b/BREAKINGCHNAGES_dev.md new file mode 100644 index 00000000..7e28013f --- /dev/null +++ b/BREAKINGCHNAGES_dev.md @@ -0,0 +1,12 @@ +1. In 13.0.0, we introduced a new feature, pushing git repos, now this feature + has been removed as some users are not satisfied with it. + + For configuration entries, the following ones are gone: + + ```toml + [git] + pull_only_repos = [] + push_only_repos = [] + pull_arguments = "" + push_arguments = "" + ``` \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c696a593..6ce03a91 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -101,6 +101,19 @@ Be sure to apply your changes to [`config.example.toml`](https://github.com/topgrade-rs/topgrade/blob/master/config.example.toml), and have some basic documentations guiding user how to use these options. +## Breaking changes + +If your PR introduces a breaking change, document it in `BREAKINGCHANGE_dev.md`, +it should be written in Markdown and wrapped in 80, for example: + +```md +1. The configuration location has been updated to x. + +2. The step x has been removed. + +3. ... +``` + ## Before you submit your PR Make sure your patch passes the following tests on your host: diff --git a/Cargo.toml b/Cargo.toml index 73cdedf7..e362f991 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ license = "GPL-3.0" repository = "https://github.com/topgrade-rs/topgrade" version = "13.0.0" authors = ["Roey Darwish Dror ", "Thomas Schönauer "] -exclude = ["doc/screenshot.gif"] +exclude = ["doc/screenshot.gif", "BREAKINGCHNAGES_dev.md"] edition = "2021" readme = "README.md" diff --git a/src/breaking_changes.rs b/src/breaking_changes.rs new file mode 100644 index 00000000..965c2e69 --- /dev/null +++ b/src/breaking_changes.rs @@ -0,0 +1,156 @@ +//! Inform the users of the breaking changes introduced in this major release. +//! +//! Print the breaking changes and possibly a migration guide when: +//! 1. The Topgrade being executed is a new major release +//! 2. This is the first launch of that major release + +use crate::terminal::print_separator; +#[cfg(windows)] +use crate::WINDOWS_DIRS; +#[cfg(unix)] +use crate::XDG_DIRS; +use color_eyre::eyre::Result; +use etcetera::base_strategy::BaseStrategy; +use std::{ + fs::{read_to_string, OpenOptions}, + io::Write, + path::PathBuf, + str::FromStr, +}; + +/// Version string x.y.z +static VERSION_STR: &str = env!("CARGO_PKG_VERSION"); + +/// Version info +#[derive(Debug)] +pub(crate) struct Version { + _major: u64, + minor: u64, + patch: u64, +} + +impl FromStr for Version { + type Err = std::convert::Infallible; + + fn from_str(s: &str) -> Result { + const NOT_SEMVER: &str = "Topgrade version is not semantic"; + const NOT_NUMBER: &str = "Topgrade version is not dot-separated numbers"; + + let mut iter = s.split('.').take(3); + let major = iter.next().expect(NOT_SEMVER).parse().expect(NOT_NUMBER); + let minor = iter.next().expect(NOT_SEMVER).parse().expect(NOT_NUMBER); + let patch = iter.next().expect(NOT_SEMVER).parse().expect(NOT_NUMBER); + + // They cannot be all 0s + assert!( + !(major == 0 && minor == 0 && patch == 0), + "Version numbers can not be all 0s" + ); + + Ok(Self { + _major: major, + minor, + patch, + }) + } +} + +impl Version { + /// True if this version is a new major release. + pub(crate) fn is_new_major_release(&self) -> bool { + // We have already checked that they cannot all be zeros, so `self.major` + // is guaranteed to be non-zero. + self.minor == 0 && self.patch == 0 + } +} + +/// Topgrade's breaking changes +/// +/// We store them in the compiled binary. +pub(crate) static BREAKINGCHANGES: &str = include_str!("../BREAKINGCHANGES.md"); + +/// Return platform's data directory. +fn data_dir() -> PathBuf { + #[cfg(unix)] + return XDG_DIRS.data_dir(); + + #[cfg(windows)] + return WINDOWS_DIRS.data_dir(); +} + +/// Return Topgrade's keep file path. +/// +/// keep file is a file under the data directory containing a major version +/// number, it will be created on first run and is used to check if an execution +/// of Topgrade is the first run of a major release, for more details, see +/// `first_run_of_major_release()`. +fn keep_file_path() -> PathBuf { + let keep_file = "topgrade_keep"; + data_dir().join(keep_file) +} + +/// True if this is the first execution of a major release. +pub(crate) fn first_run_of_major_release() -> Result { + let version = VERSION_STR.parse::().expect("should be a valid version"); + let keep_file = keep_file_path(); + + // disable this lint here as the current code has better readability + #[allow(clippy::collapsible_if)] + if version.is_new_major_release() { + if !keep_file.exists() || read_to_string(&keep_file)? != VERSION_STR { + return Ok(true); + } + } + + Ok(false) +} + +/// Print breaking changes to the user. +pub(crate) fn print_breaking_changes() { + let header = format!("Topgrade {VERSION_STR} Breaking Changes"); + print_separator(header); + let contents = if BREAKINGCHANGES.is_empty() { + "No Breaking changes" + } else { + BREAKINGCHANGES + }; + println!("{contents}\n"); +} + +/// This function will be ONLY executed when the user has confirmed the breaking +/// changes, once confirmed, we write the keep file, which means the first run +/// of this major release is finished. +pub(crate) fn write_keep_file() -> Result<()> { + std::fs::create_dir_all(data_dir())?; + let keep_file = keep_file_path(); + + let mut file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(keep_file)?; + let _ = file.write(VERSION_STR.as_bytes())?; + + Ok(()) +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn is_new_major_release_works() { + let first_major_release: Version = "1.0.0".parse().unwrap(); + let under_dev: Version = "0.1.0".parse().unwrap(); + + assert!(first_major_release.is_new_major_release()); + assert!(!under_dev.is_new_major_release()); + } + + #[test] + #[should_panic(expected = "Version numbers can not be all 0s")] + fn invalid_version() { + let all_0 = "0.0.0"; + all_0.parse::().unwrap(); + } +} diff --git a/src/main.rs b/src/main.rs index af5e4dbb..a91b76a0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,14 +6,17 @@ use std::path::PathBuf; use std::process::exit; use std::time::Duration; +use crate::breaking_changes::{first_run_of_major_release, print_breaking_changes, write_keep_file}; use clap::CommandFactory; use clap::{crate_version, Parser}; use color_eyre::eyre::Context; use color_eyre::eyre::Result; use console::Key; +use etcetera::base_strategy::BaseStrategy; #[cfg(windows)] use etcetera::base_strategy::Windows; -use etcetera::base_strategy::{BaseStrategy, Xdg}; +#[cfg(unix)] +use etcetera::base_strategy::Xdg; use once_cell::sync::Lazy; use tracing::debug; @@ -26,6 +29,7 @@ use self::terminal::*; use self::utils::{install_color_eyre, install_tracing, update_tracing}; +mod breaking_changes; mod command; mod config; mod ctrlc; @@ -43,10 +47,11 @@ mod sudo; mod terminal; mod utils; -pub static HOME_DIR: Lazy = Lazy::new(|| home::home_dir().expect("No home directory")); -pub static XDG_DIRS: Lazy = Lazy::new(|| Xdg::new().expect("No home directory")); +pub(crate) static HOME_DIR: Lazy = Lazy::new(|| home::home_dir().expect("No home directory")); +#[cfg(unix)] +pub(crate) static XDG_DIRS: Lazy = Lazy::new(|| Xdg::new().expect("No home directory")); #[cfg(windows)] -pub static WINDOWS_DIRS: Lazy = Lazy::new(|| Windows::new().expect("No home directory")); +pub(crate) static WINDOWS_DIRS: Lazy = Lazy::new(|| Windows::new().expect("No home directory")); fn run() -> Result<()> { install_color_eyre()?; @@ -130,6 +135,18 @@ fn run() -> Result<()> { let ctx = execution_context::ExecutionContext::new(run_type, sudo, &git, &config); let mut runner = runner::Runner::new(&ctx); + // If this is the first execution of a major release, inform user of breaking + // changes + if first_run_of_major_release()? { + print_breaking_changes(); + + if prompt_yesno("Confirmed?")? { + write_keep_file()?; + } else { + exit(1); + } + } + // Self-Update step, this will execute only if: // 1. the `self-update` feature is enabled // 2. it is not disabled from configuration (env var/CLI opt/file)