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 =
129                tag_id.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                    let _ = crate::ops::shell::write_stderr(&prefix, &NOP);
192                    let _ = crate::ops::shell::write_stderr(&commit.short_id, &WARN);
193                    let _ = crate::ops::shell::write_stderr(" ", &NOP);
194                    let _ = crate::ops::shell::write_stderr(&commit.summary, &NOP);
195
196                    let current_status = commit.status();
197                    write_status(current_status);
198                    let _ = crate::ops::shell::write_stderr("\n", &NOP);
199                    match (current_status, max_status) {
200                        (Some(cur), Some(max)) => {
201                            max_status = Some(cur.max(max));
202                        }
203                        (Some(s), None) | (None, Some(s)) => {
204                            max_status = Some(s);
205                        }
206                        (None, None) => {}
207                    }
208                }
209                if version.full_version.is_prerelease() {
210                    // Enough unknowns about pre-release to not bother
211                    max_status = None;
212                }
213                let unbumped = pkg
214                    .planned_tag
215                    .as_deref()
216                    .and_then(|t| git::tag_exists(workspace_root, t).ok())
217                    .unwrap_or(false);
218                let bumped = !unbumped;
219                if let Some(max_status) = max_status {
220                    let suggested = match max_status {
221                        CommitStatus::Breaking => {
222                            match (
223                                version.full_version.major,
224                                version.full_version.minor,
225                                version.full_version.patch,
226                            ) {
227                                (0, 0, _) if bumped => None,
228                                (0, 0, _) => Some("patch"),
229                                (0, _, 0) if bumped => None,
230                                (0, _, _) => Some("minor"),
231                                (_, 0, 0) if bumped => None,
232                                (_, _, _) => Some("major"),
233                            }
234                        }
235                        CommitStatus::Feature => {
236                            match (
237                                version.full_version.major,
238                                version.full_version.minor,
239                                version.full_version.patch,
240                            ) {
241                                (0, 0, _) if bumped => None,
242                                (0, 0, _) => Some("patch"),
243                                (0, _, _) if bumped => None,
244                                (0, _, _) => Some("patch"),
245                                (_, _, 0) if bumped => None,
246                                (_, _, _) => Some("minor"),
247                            }
248                        }
249                        CommitStatus::Fix if bumped => None,
250                        CommitStatus::Fix => Some("patch"),
251                        CommitStatus::Ignore => None,
252                    };
253                    if let Some(suggested) = suggested {
254                        let _ = crate::ops::shell::note(format!(
255                            "to update the version, run `cargo release version -p {crate_name} {suggested}`"
256                        ));
257                    } else if unbumped {
258                        let _ = crate::ops::shell::note(format!(
259                            "to update the version, run `cargo release version -p {crate_name} <LEVEL|VERSION>`"
260                        ));
261                    }
262                }
263            }
264        } else {
265            log::debug!(
266                "Cannot detect changes for {crate_name} because no tag was found. Try setting `--prev-tag-name <TAG>`.",
267            );
268        }
269    }
270
271    Ok(())
272}
273
274fn write_status(status: Option<CommitStatus>) {
275    if let Some(status) = status {
276        let suffix;
277        let mut style = NOP;
278        match status {
279            CommitStatus::Breaking => {
280                suffix = " (breaking)";
281                style = ERROR;
282            }
283            CommitStatus::Feature => {
284                suffix = " (feature)";
285                style = WARN;
286            }
287            CommitStatus::Fix => {
288                suffix = " (fix)";
289                style = GOOD;
290            }
291            CommitStatus::Ignore => {
292                suffix = "";
293            }
294        }
295        let _ = crate::ops::shell::write_stderr(suffix, &style);
296    }
297}
298
299#[derive(Clone, Debug)]
300pub struct PackageCommit {
301    pub id: git2::Oid,
302    pub short_id: String,
303    pub summary: String,
304    pub message: String,
305    pub paths: std::collections::BTreeSet<std::path::PathBuf>,
306}
307
308impl PackageCommit {
309    pub fn status(&self) -> Option<CommitStatus> {
310        if let Some(status) = self.conventional_status() {
311            return status;
312        }
313
314        None
315    }
316
317    fn conventional_status(&self) -> Option<Option<CommitStatus>> {
318        let parts = git_conventional::Commit::parse(&self.message).ok()?;
319        if parts.breaking() {
320            return Some(Some(CommitStatus::Breaking));
321        }
322
323        if [
324            git_conventional::Type::CHORE,
325            git_conventional::Type::TEST,
326            git_conventional::Type::STYLE,
327            git_conventional::Type::REFACTOR,
328            git_conventional::Type::REVERT,
329        ]
330        .contains(&parts.type_())
331        {
332            Some(Some(CommitStatus::Ignore))
333        } else if [
334            git_conventional::Type::DOCS,
335            git_conventional::Type::PERF,
336            git_conventional::Type::FIX,
337        ]
338        .contains(&parts.type_())
339        {
340            Some(Some(CommitStatus::Fix))
341        } else if [git_conventional::Type::FEAT].contains(&parts.type_()) {
342            Some(Some(CommitStatus::Feature))
343        } else {
344            Some(None)
345        }
346    }
347}
348
349#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
350pub enum CommitStatus {
351    Ignore,
352    Fix,
353    Feature,
354    Breaking,
355}