cargo_release/steps/
changes.rs

1use crate::error::CargoResult;
2use crate::error::CliError;
3use crate::ops::git;
4use crate::ops::version::VersionExt as _;
5use crate::steps::plan;
6use clap_cargo::style::{ERROR, GOOD, NOP, WARN};
7
8/// Print commits since last tag
9#[derive(Debug, Clone, clap::Args)]
10pub struct ChangesStep {
11    #[command(flatten)]
12    manifest: clap_cargo::Manifest,
13
14    /// Custom config file
15    #[arg(short, long = "config", value_name = "PATH")]
16    custom_config: Option<std::path::PathBuf>,
17
18    /// Ignore implicit configuration files.
19    #[arg(long)]
20    isolated: bool,
21
22    /// Unstable options
23    #[arg(short = 'Z', value_name = "FEATURE")]
24    z: Vec<crate::config::UnstableValues>,
25
26    /// Comma-separated globs of branch names a release can happen from
27    #[arg(long, value_delimiter = ',')]
28    allow_branch: Option<Vec<String>>,
29
30    /// The name of tag for the previous release.
31    #[arg(long, value_name = "NAME", help_heading = "Version")]
32    prev_tag_name: Option<String>,
33}
34
35impl ChangesStep {
36    pub fn run(&self) -> Result<(), CliError> {
37        git::git_version()?;
38
39        let ws_meta = self
40            .manifest
41            .metadata()
42            // When evaluating dependency ordering, we need to consider optional dependencies
43            .features(cargo_metadata::CargoOpt::AllFeatures)
44            .exec()?;
45        let config = self.to_config();
46        let ws_config = crate::config::load_workspace_config(&config, &ws_meta)?;
47        let mut pkgs = plan::load(&config, &ws_meta)?;
48
49        for pkg in pkgs.values_mut() {
50            if let Some(prev_tag) = self.prev_tag_name.as_ref() {
51                // Trust the user that the tag passed in is the latest tag for the workspace and that
52                // they don't care about any changes from before this tag.
53                pkg.set_prior_tag(prev_tag.to_owned());
54            }
55        }
56
57        let pkgs = plan::plan(pkgs)?;
58
59        let (selected_pkgs, _excluded_pkgs): (Vec<_>, Vec<_>) = pkgs
60            .into_iter()
61            .map(|(_, pkg)| pkg)
62            .partition(|p| p.config.release());
63        if selected_pkgs.is_empty() {
64            log::info!("No packages selected.");
65            return Err(2.into());
66        }
67
68        let dry_run = false;
69        let mut failed = false;
70
71        // STEP 0: Help the user make the right decisions.
72        failed |= !super::verify_git_is_clean(
73            ws_meta.workspace_root.as_std_path(),
74            dry_run,
75            log::Level::Warn,
76        )?;
77
78        failed |= !super::verify_git_branch(
79            ws_meta.workspace_root.as_std_path(),
80            &ws_config,
81            dry_run,
82            log::Level::Warn,
83        )?;
84
85        failed |= !super::verify_if_behind(
86            ws_meta.workspace_root.as_std_path(),
87            &ws_config,
88            dry_run,
89            log::Level::Warn,
90        )?;
91
92        changes(&ws_meta, &selected_pkgs)?;
93
94        super::finish(failed, dry_run)
95    }
96
97    fn to_config(&self) -> crate::config::ConfigArgs {
98        crate::config::ConfigArgs {
99            custom_config: self.custom_config.clone(),
100            isolated: self.isolated,
101            allow_branch: self.allow_branch.clone(),
102            ..Default::default()
103        }
104    }
105}
106
107pub fn changes(
108    ws_meta: &cargo_metadata::Metadata,
109    selected_pkgs: &[plan::PackageRelease],
110) -> CargoResult<()> {
111    for pkg in selected_pkgs {
112        let version = pkg.planned_version.as_ref().unwrap_or(&pkg.initial_version);
113        let crate_name = pkg.meta.name.as_str();
114        if let Some(prior_tag_name) = &pkg.prior_tag {
115            let workspace_root = ws_meta.workspace_root.as_std_path();
116            let repo = git2::Repository::discover(workspace_root)?;
117
118            let mut tag_id = None;
119            let fq_prior_tag_name = format!("refs/tags/{prior_tag_name}");
120            repo.tag_foreach(|id, name| {
121                if name == fq_prior_tag_name.as_bytes() {
122                    tag_id = Some(id);
123                    false
124                } else {
125                    true
126                }
127            })?;
128            let tag_id = tag_id
129                .ok_or_else(|| anyhow::format_err!("could not find tag {}", prior_tag_name))?;
130
131            let head_id = repo.head()?.peel_to_commit()?.id();
132
133            let mut revwalk = repo.revwalk()?;
134            revwalk.push_range(&format!("{tag_id}..{head_id}"))?;
135
136            let mut commits = Vec::new();
137            for commit_id in revwalk {
138                let commit_id = commit_id?;
139                let commit = repo.find_commit(commit_id)?;
140                if 1 < commit.parent_count() {
141                    // Assuming merge commits can be ignored
142                    continue;
143                }
144                let parent_tree = commit.parent(0).ok().map(|c| c.tree()).transpose()?;
145                let tree = commit.tree()?;
146                let diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), None)?;
147
148                let mut changed_paths = std::collections::BTreeSet::new();
149                for delta in diff.deltas() {
150                    let old_path = delta.old_file().path();
151                    let new_path = delta.new_file().path();
152                    for entry_relpath in [old_path, new_path].into_iter().flatten() {
153                        for path in pkg
154                            .package_content
155                            .iter()
156                            .filter_map(|p| p.strip_prefix(workspace_root).ok())
157                        {
158                            if path == entry_relpath {
159                                changed_paths.insert(path.to_owned());
160                            }
161                        }
162                    }
163                }
164
165                if !changed_paths.is_empty() {
166                    let short_id =
167                        String::from_utf8_lossy(&repo.find_object(commit_id, None)?.short_id()?)
168                            .into_owned();
169                    commits.push(PackageCommit {
170                        id: commit_id,
171                        short_id,
172                        summary: String::from_utf8_lossy(commit.summary_bytes().unwrap_or(b""))
173                            .into_owned(),
174                        message: String::from_utf8_lossy(commit.message_bytes()).into_owned(),
175                        paths: changed_paths,
176                    });
177                }
178            }
179
180            if !commits.is_empty() {
181                crate::ops::shell::status(
182                    "Changes",
183                    format!(
184                        "for {} from {} to {}",
185                        crate_name, prior_tag_name, version.full_version_string
186                    ),
187                )?;
188                let prefix = format!("{:>13}", " ");
189                let mut max_status = None;
190                for commit in &commits {
191                    #[allow(clippy::needless_borrow)] // False positive
192                    let _ = crate::ops::shell::write_stderr(&prefix, &NOP);
193                    let _ = crate::ops::shell::write_stderr(&commit.short_id, &WARN);
194                    let _ = crate::ops::shell::write_stderr(" ", &NOP);
195                    let _ = crate::ops::shell::write_stderr(&commit.summary, &NOP);
196
197                    let current_status = commit.status();
198                    write_status(current_status);
199                    let _ = crate::ops::shell::write_stderr("\n", &NOP);
200                    match (current_status, max_status) {
201                        (Some(cur), Some(max)) => {
202                            max_status = Some(cur.max(max));
203                        }
204                        (Some(s), None) | (None, Some(s)) => {
205                            max_status = Some(s);
206                        }
207                        (None, None) => {}
208                    }
209                }
210                if version.full_version.is_prerelease() {
211                    // Enough unknowns about pre-release to not bother
212                    max_status = None;
213                }
214                let unbumped = pkg
215                    .planned_tag
216                    .as_deref()
217                    .and_then(|t| git::tag_exists(workspace_root, t).ok())
218                    .unwrap_or(false);
219                let bumped = !unbumped;
220                if let Some(max_status) = max_status {
221                    let suggested = match max_status {
222                        CommitStatus::Breaking => {
223                            match (
224                                version.full_version.major,
225                                version.full_version.minor,
226                                version.full_version.patch,
227                            ) {
228                                (0, 0, _) if bumped => None,
229                                (0, 0, _) => Some("patch"),
230                                (0, _, 0) if bumped => None,
231                                (0, _, _) => Some("minor"),
232                                (_, 0, 0) if bumped => None,
233                                (_, _, _) => Some("major"),
234                            }
235                        }
236                        CommitStatus::Feature => {
237                            match (
238                                version.full_version.major,
239                                version.full_version.minor,
240                                version.full_version.patch,
241                            ) {
242                                (0, 0, _) if bumped => None,
243                                (0, 0, _) => Some("patch"),
244                                (0, _, _) if bumped => None,
245                                (0, _, _) => Some("patch"),
246                                (_, _, 0) if bumped => None,
247                                (_, _, _) => Some("minor"),
248                            }
249                        }
250                        CommitStatus::Fix if bumped => None,
251                        CommitStatus::Fix => Some("patch"),
252                        CommitStatus::Ignore => None,
253                    };
254                    if let Some(suggested) = suggested {
255                        let _ = crate::ops::shell::note(format!(
256                            "to update the version, run `cargo release version -p {crate_name} {suggested}`"
257                        ));
258                    } else if unbumped {
259                        let _ = crate::ops::shell::note(format!(
260                            "to update the version, run `cargo release version -p {crate_name} <LEVEL|VERSION>`"
261                        ));
262                    }
263                }
264            }
265        } else {
266            log::debug!(
267                "Cannot detect changes for {} because no tag was found. Try setting `--prev-tag-name <TAG>`.",
268                crate_name,
269            );
270        }
271    }
272
273    Ok(())
274}
275
276fn write_status(status: Option<CommitStatus>) {
277    if let Some(status) = status {
278        let suffix;
279        let mut style = NOP;
280        match status {
281            CommitStatus::Breaking => {
282                suffix = " (breaking)";
283                style = ERROR;
284            }
285            CommitStatus::Feature => {
286                suffix = " (feature)";
287                style = WARN;
288            }
289            CommitStatus::Fix => {
290                suffix = " (fix)";
291                style = GOOD;
292            }
293            CommitStatus::Ignore => {
294                suffix = "";
295            }
296        }
297        let _ = crate::ops::shell::write_stderr(suffix, &style);
298    }
299}
300
301#[derive(Clone, Debug)]
302pub struct PackageCommit {
303    pub id: git2::Oid,
304    pub short_id: String,
305    pub summary: String,
306    pub message: String,
307    pub paths: std::collections::BTreeSet<std::path::PathBuf>,
308}
309
310impl PackageCommit {
311    pub fn status(&self) -> Option<CommitStatus> {
312        if let Some(status) = self.conventional_status() {
313            return status;
314        }
315
316        None
317    }
318
319    fn conventional_status(&self) -> Option<Option<CommitStatus>> {
320        let parts = git_conventional::Commit::parse(&self.message).ok()?;
321        if parts.breaking() {
322            return Some(Some(CommitStatus::Breaking));
323        }
324
325        if [
326            git_conventional::Type::CHORE,
327            git_conventional::Type::TEST,
328            git_conventional::Type::STYLE,
329            git_conventional::Type::REFACTOR,
330            git_conventional::Type::REVERT,
331        ]
332        .contains(&parts.type_())
333        {
334            Some(Some(CommitStatus::Ignore))
335        } else if [
336            git_conventional::Type::DOCS,
337            git_conventional::Type::PERF,
338            git_conventional::Type::FIX,
339        ]
340        .contains(&parts.type_())
341        {
342            Some(Some(CommitStatus::Fix))
343        } else if [git_conventional::Type::FEAT].contains(&parts.type_()) {
344            Some(Some(CommitStatus::Feature))
345        } else {
346            Some(None)
347        }
348    }
349}
350
351#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
352pub enum CommitStatus {
353    Ignore,
354    Fix,
355    Feature,
356    Breaking,
357}