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 = 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 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)] 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 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}