Skip to content

Commit a71d838

Browse files
authored
feat(report): add cargo report rebuilds (#16456)
### What does this PR try to resolve? This will improve <#2904>. Part of <#15844>. Adds a new command to analyze rebuild reasons from previous sessions. The report includes: * overview of rebuild/cached/new unit counts * root rebuilds sorted by number of cascading rebuilds * `-v` to show all root rebuilds (default showing 5) * `-vv` to show affected unit lists (default collapsed) This command doesn't have filtering by package or reason yet. Can be added when we have use cases. Example output: ```console Session: 20251231T204416809Z-a5db680cc3bc96e4 Status: 3 units rebuilt, 0 cached, 0 new Rebuild impact: root rebuilds: 1 unit cascading: 2 units Root rebuilds: 0. [email protected] (check): file modified: common/src/lib.rs impact: 2 dependent units rebuilt [NOTE] pass `-vv` to show all affected rebuilt unit lists ``` ```console $ cargo report rebuilds --verbose` Session: 20251231T204416809Z-a5db680cc3bc96e4 Status: 6 units rebuilt, 0 cached, 0 new Rebuild impact: root rebuilds: 6 units cascading: 0 units Root rebuilds: 0. [email protected] (check): declared features changed: [] -> ["feat"] impact: no cascading rebuilds 1. [email protected] (check): file modified: pkg2/src/lib.rs impact: no cascading rebuilds 2. [email protected] (check): target configuration changed impact: no cascading rebuilds 3. [email protected] (check): environment variable changed (__CARGO_TEST_MY_FOO): <unset> -> 1 impact: no cascading rebuilds 4. [email protected] (check): file modified: pkg5/src/lib.rs impact: no cascading rebuilds 5. [email protected] (check): file modified: pkg6/src/lib.rs impact: no cascading rebuilds ``` ### How to test and review this PR? I found it awkward to logging fingerprint, at least haven't found a reason way to implement that. This came out first as a basic version of rebuild reporting. ### Open questions * Should this show a tree view like `cargo tree`? * Personally I think `cargo report builds` rignt how should have only a basic version to show the usefulness of log messages, and we can expand later if needed. * Should we also show cached unit? * Probably not. I aim to provide a command to answer the most frequently asked question: What is the root cause of this rebuild. While some of the info is not really useful and has no follow-up user action, we can improve them later when dirty reason become more exposed to users. ### Screenshots Built rustfix after `cargo update syn@2` <img width="497" height="231" alt="image" src="https://github.com/user-attachments/assets/50fc6291-0741-46be-8297-2b21d508d557" /> <img width="391" height="280" alt="image" src="https://github.com/user-attachments/assets/03fa147d-b0c8-450f-8d57-4741ceefe6be" />
2 parents 6598fea + 872f02c commit a71d838

20 files changed

Lines changed: 1287 additions & 122 deletions

File tree

src/bin/cargo/commands/report.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ pub fn cli() -> Command {
4444
.default_value("10"),
4545
),
4646
)
47+
.subcommand(
48+
subcommand("rebuilds")
49+
.about("Reports rebuild reasons from previous sessions (unstable)")
50+
.arg_manifest_path(),
51+
)
4752
}
4853

4954
pub fn exec(gctx: &mut GlobalContext, args: &ArgMatches) -> CliResult {
@@ -75,6 +80,19 @@ pub fn exec(gctx: &mut GlobalContext, args: &ArgMatches) -> CliResult {
7580
ops::report_sessions(gctx, ws.as_ref(), opts)?;
7681
Ok(())
7782
}
83+
Some(("rebuilds", args)) => {
84+
gctx.cli_unstable().fail_if_stable_command(
85+
gctx,
86+
"report rebuilds",
87+
15844,
88+
"build-analysis",
89+
gctx.cli_unstable().build_analysis,
90+
)?;
91+
let ws = args.workspace(gctx).ok();
92+
let opts = rebuilds_opts(args)?;
93+
ops::report_rebuilds(gctx, ws.as_ref(), opts)?;
94+
Ok(())
95+
}
7896
Some((cmd, _)) => {
7997
unreachable!("unexpected command {}", cmd)
8098
}
@@ -112,3 +130,7 @@ fn sessions_opts(args: &ArgMatches) -> CargoResult<ops::ReportSessionsOptions> {
112130

113131
Ok(ops::ReportSessionsOptions { limit })
114132
}
133+
134+
fn rebuilds_opts(_args: &ArgMatches) -> CargoResult<ops::ReportRebuildsOptions> {
135+
Ok(ops::ReportRebuildsOptions {})
136+
}

src/cargo/core/compiler/fingerprint/dep_info.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -678,6 +678,16 @@ impl Serialize for Checksum {
678678
}
679679
}
680680

681+
impl<'de> serde::Deserialize<'de> for Checksum {
682+
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
683+
where
684+
D: serde::Deserializer<'de>,
685+
{
686+
let s = String::deserialize(deserializer)?;
687+
s.parse().map_err(serde::de::Error::custom)
688+
}
689+
}
690+
681691
#[derive(Debug, thiserror::Error)]
682692
pub enum InvalidChecksum {
683693
#[error("algorithm portion incorrect, expected `sha256`, or `blake3`")]

src/cargo/core/compiler/fingerprint/dirty_reason.rs

Lines changed: 37 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use std::collections::HashMap;
12
use std::fmt;
23
use std::fmt::Debug;
34

@@ -10,7 +11,7 @@ use crate::core::Shell;
1011
/// to a recompile. Usually constructed via [`Fingerprint::compare`].
1112
///
1213
/// [`Fingerprint::compare`]: super::Fingerprint::compare
13-
#[derive(Clone, Debug, Serialize)]
14+
#[derive(Clone, Debug, Serialize, Deserialize)]
1415
#[serde(tag = "dirty_reason", rename_all = "kebab-case")]
1516
pub enum DirtyReason {
1617
RustcChanged,
@@ -61,8 +62,8 @@ pub enum DirtyReason {
6162
new_value: Option<String>,
6263
},
6364
LocalFingerprintTypeChanged {
64-
old: &'static str,
65-
new: &'static str,
65+
old: String,
66+
new: String,
6667
},
6768
NumberOfDependenciesChanged {
6869
old: usize,
@@ -73,11 +74,7 @@ pub enum DirtyReason {
7374
new: InternedString,
7475
},
7576
UnitDependencyInfoChanged {
76-
old_name: InternedString,
77-
old_fingerprint: u64,
78-
79-
new_name: InternedString,
80-
new_fingerprint: u64,
77+
unit: u64,
8178
},
8279
FsStatusOutdated(FsStatus),
8380
NothingObvious,
@@ -148,7 +145,13 @@ impl DirtyReason {
148145
}
149146
}
150147

151-
pub fn present_to(&self, s: &mut Shell, unit: &Unit, root: &Path) -> CargoResult<()> {
148+
pub fn present_to(
149+
&self,
150+
s: &mut Shell,
151+
unit: &Unit,
152+
root: &Path,
153+
index_to_unit: &HashMap<u64, Unit>,
154+
) -> CargoResult<()> {
152155
match self {
153156
DirtyReason::RustcChanged => s.dirty_because(unit, "the toolchain changed"),
154157
DirtyReason::FeaturesChanged { .. } => {
@@ -220,8 +223,12 @@ impl DirtyReason {
220223
unit,
221224
format_args!("name of dependency changed ({old} => {new})"),
222225
),
223-
DirtyReason::UnitDependencyInfoChanged { .. } => {
224-
s.dirty_because(unit, "dependency info changed")
226+
DirtyReason::UnitDependencyInfoChanged { unit: dep_unit } => {
227+
let dep_name = index_to_unit.get(dep_unit).map(|u| u.pkg.name()).unwrap();
228+
s.dirty_because(
229+
unit,
230+
format_args!("info of dependency `{dep_name}` changed"),
231+
)
225232
}
226233
DirtyReason::FsStatusOutdated(status) => match status {
227234
FsStatus::Stale => s.dirty_because(unit, "stale, unknown reason"),
@@ -301,19 +308,23 @@ impl DirtyReason {
301308
),
302309
},
303310
FsStatus::StaleDependency {
304-
name,
311+
unit: dep_unit,
305312
dep_mtime,
306313
max_mtime,
307-
..
308314
} => {
315+
let dep_name = index_to_unit.get(dep_unit).map(|u| u.pkg.name()).unwrap();
309316
let after = Self::after(*max_mtime, *dep_mtime, "last build");
310317
s.dirty_because(
311318
unit,
312-
format_args!("the dependency {name} was rebuilt ({after})"),
319+
format_args!("the dependency `{dep_name}` was rebuilt ({after})"),
313320
)
314321
}
315-
FsStatus::StaleDepFingerprint { name } => {
316-
s.dirty_because(unit, format_args!("the dependency {name} was rebuilt"))
322+
FsStatus::StaleDepFingerprint { unit: dep_unit } => {
323+
let dep_name = index_to_unit.get(dep_unit).map(|u| u.pkg.name()).unwrap();
324+
s.dirty_because(
325+
unit,
326+
format_args!("the dependency `{dep_name}` was rebuilt"),
327+
)
317328
}
318329
FsStatus::UpToDate { .. } => {
319330
unreachable!()
@@ -529,8 +540,8 @@ mod json_schema {
529540
str![[r#"
530541
{
531542
"dirty_reason": "unit-dependency-name-changed",
532-
"old": "old_dep",
533-
"new": "new_dep"
543+
"new": "new_dep",
544+
"old": "old_dep"
534545
}
535546
"#]]
536547
.is_json()
@@ -539,21 +550,13 @@ mod json_schema {
539550

540551
#[test]
541552
fn unit_dependency_info_changed() {
542-
let reason = DirtyReason::UnitDependencyInfoChanged {
543-
old_name: "serde".into(),
544-
old_fingerprint: 0x1234567890abcdef,
545-
new_name: "serde".into(),
546-
new_fingerprint: 0xfedcba0987654321,
547-
};
553+
let reason = DirtyReason::UnitDependencyInfoChanged { unit: 15 };
548554
assert_data_eq!(
549555
to_json(&reason),
550556
str![[r#"
551557
{
552558
"dirty_reason": "unit-dependency-info-changed",
553-
"new_fingerprint": 18364757930599072545,
554-
"new_name": "serde",
555-
"old_fingerprint": 1311768467294899695,
556-
"old_name": "serde"
559+
"unit": 15
557560
}
558561
"#]]
559562
.is_json()
@@ -647,7 +650,7 @@ mod json_schema {
647650
#[test]
648651
fn fs_status_stale_dependency() {
649652
let reason = DirtyReason::FsStatusOutdated(FsStatus::StaleDependency {
650-
name: "serde".into(),
653+
unit: 42,
651654
dep_mtime: FileTime::from_unix_time(1730567892, 789000000),
652655
max_mtime: FileTime::from_unix_time(1730567890, 123000000),
653656
});
@@ -659,7 +662,7 @@ mod json_schema {
659662
"dirty_reason": "fs-status-outdated",
660663
"fs_status": "stale-dependency",
661664
"max_mtime": 1730567890123.0,
662-
"name": "serde"
665+
"unit": 42
663666
}
664667
"#]]
665668
.is_json()
@@ -668,16 +671,14 @@ mod json_schema {
668671

669672
#[test]
670673
fn fs_status_stale_dep_fingerprint() {
671-
let reason = DirtyReason::FsStatusOutdated(FsStatus::StaleDepFingerprint {
672-
name: "tokio".into(),
673-
});
674+
let reason = DirtyReason::FsStatusOutdated(FsStatus::StaleDepFingerprint { unit: 42 });
674675
assert_data_eq!(
675676
to_json(&reason),
676677
str![[r#"
677678
{
678679
"dirty_reason": "fs-status-outdated",
679680
"fs_status": "stale-dep-fingerprint",
680-
"name": "tokio"
681+
"unit": 42
681682
}
682683
"#]]
683684
.is_json()
@@ -830,8 +831,8 @@ mod json_schema {
830831
#[test]
831832
fn local_fingerprint_type_changed() {
832833
let reason = DirtyReason::LocalFingerprintTypeChanged {
833-
old: "precalculated",
834-
new: "rerun-if-changed",
834+
old: "precalculated".to_owned(),
835+
new: "rerun-if-changed".to_owned(),
835836
};
836837
assert_data_eq!(
837838
to_json(&reason),

0 commit comments

Comments
 (0)