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#[derive(Debug, Clone, clap::Args)]
10pub struct ChangesStep {
11 #[command(flatten)]
12 manifest: clap_cargo::Manifest,
13
14 #[arg(short, long = "config", value_name = "PATH")]
16 custom_config: Option<std::path::PathBuf>,
17
18 #[arg(long)]
20 isolated: bool,
21
22 #[arg(short = 'Z', value_name = "FEATURE")]
24 z: Vec<crate::config::UnstableValues>,
25
26 #[arg(long, value_delimiter = ',')]
28 allow_branch: Option<Vec<String>>,
29
30 #[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 .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 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 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 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 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}