//! Patch series, driven through the built binary against a fixture tree. //! //! Every test name here carries the verb, so the plan's own command //! `cargo test --locked series` selects exactly this file's tests. mod common; use std::path::{Path, PathBuf}; use common::Fixture; /// The blurb the tests write into a cover letter. const BLURB: &str = "Port the re-arm to v2\n\nThis replaces the v1 series after review."; /// A tree with two commits on a branch of its own: the smallest thing that is /// really a set rather than a patch. fn fixture_with_two_commits() -> Fixture { let fixture = Fixture::kernel_tree(); fixture.work_on_a_branch(); fixture.write("drivers/foo/bar.c", common::EDITED); fixture.commit_all("foo: return a + 1"); fixture.write( "drivers/foo/bar.c", "static int foo(int a)\n{\n\treturn a + 2;\n}\n", ); fixture.commit_all("foo: return a + 2"); fixture } /// The directory a series was written into, as `create` printed it. fn create_series(fixture: &Fixture, extra: &[&str]) -> PathBuf { let mut args = vec!["patch", "create", "xhci-series", "--range", "master..HEAD"]; args.extend_from_slice(extra); let output = fixture.cli(&args); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); PathBuf::from(String::from_utf8_lossy(&output.stdout).trim()) } /// What is in a directory, sorted, so two listings can be compared. fn listing(dir: &Path) -> Vec { let mut names: Vec = std::fs::read_dir(dir) .expect("read the series directory") .map(|entry| { entry .expect("an entry") .file_name() .to_string_lossy() .into_owned() }) .collect(); names.sort(); names } /// The patches in a directory, in name order. fn patches(dir: &Path) -> Vec { listing(dir) .into_iter() .filter(|name| name.ends_with(".patch")) .collect() } fn read(dir: &Path, name: &str) -> String { std::fs::read_to_string(dir.join(name)).expect("read a file in the series directory") } #[test] fn a_series_gets_the_names_and_the_cover_letter_format_patch_gives_it() { let fixture = fixture_with_two_commits(); let dir = create_series(&fixture, &["--cover-letter", BLURB]); assert_eq!( dir, fixture.home().join(".spectral/patches/xhci-series"), "the series belongs in a directory of its own" ); let names = listing(&dir); assert_eq!(names.len(), 4, "{names:?}"); assert_eq!(names[0], "0000-cover-letter.patch"); assert!(names[1].starts_with("0001-"), "{names:?}"); assert!(names[2].starts_with("0002-"), "{names:?}"); assert_eq!(names[3], "series.toml", "the sidecar says what this is"); // The sidecar records the whole range as hashes, the revision, and the // files in the order they are sent. The range matters as much as the base: // a re-roll rebuilt as `base..HEAD` would pick up later commits. let manifest = read(&dir, "series.toml"); let master = fixture.git(&["rev-parse", "master"]); let head = fixture.git(&["rev-parse", "HEAD"]); assert!( manifest.contains(&format!("base = \"{master}\"")), "the base is a hash: {manifest}" ); assert!( manifest.contains(&format!("head = \"{head}\"")), "the head is a hash too: {manifest}" ); assert!(manifest.contains("version = 1"), "{manifest}"); assert!(manifest.contains("0000-cover-letter.patch"), "{manifest}"); // The blurb landed, and the template that send-email refuses is gone. let cover = read(&dir, "0000-cover-letter.patch"); assert!( cover.contains("Subject: [PATCH 0/2] Port the re-arm to v2"), "{cover}" ); assert!(cover.contains("This replaces the v1 series"), "{cover}"); assert!(!cover.contains("*** BLURB HERE ***"), "{cover}"); assert!(!cover.contains("*** SUBJECT HERE ***"), "{cover}"); } #[test] fn a_series_goes_out_in_one_send_email_invocation() { let fixture = fixture_with_two_commits(); create_series(&fixture, &["--cover-letter", BLURB]); let output = fixture.cli(&["patch", "submit", "xhci-series", "--dry-run"]); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8_lossy(&output.stdout); let command = stdout .lines() .find(|line| line.contains("send-email --dry-run")) .unwrap_or_else(|| panic!("no command line in {stdout}")); for name in ["0000-cover-letter.patch", "0001-", "0002-"] { assert!(command.contains(name), "{name} is not in {command}"); } let cover_at = command.find("0000-cover-letter").expect("the cover letter"); let first_at = command.find("0001-").expect("the first patch"); assert!( cover_at < first_at, "the cover letter is sent first: {command}" ); assert!(stdout.contains("Dry-OK"), "{stdout}"); // One lookup over the whole set: a series has one recipient list. let call = fixture.read(".getmaintainer-call"); assert_eq!( call.matches("argv:").count(), 1, "the lookup ran more than once: {call}" ); assert!(call.contains("0000-cover-letter.patch"), "{call}"); assert!(call.contains("0001-"), "{call}"); } #[test] fn a_series_without_a_blurb_is_refused_by_send_email() { // No --cover-letter, so format-patch's template subject is still in the // file and send-email refuses the set. The refusal has to reach the user. let fixture = fixture_with_two_commits(); create_series(&fixture, &[]); let output = fixture.cli(&["patch", "submit", "xhci-series", "--dry-run"]); assert_eq!(output.status.code(), Some(1)); let stderr = String::from_utf8_lossy(&output.stderr); assert!(stderr.contains("Refusing to send"), "{stderr}"); assert!(stderr.contains("exit 25"), "{stderr}"); } #[test] fn series_reroll_moves_every_file_at_once_and_stays_idempotent() { let fixture = fixture_with_two_commits(); let dir = create_series(&fixture, &["--cover-letter", BLURB]); let before = listing(&dir); // No -v: the next revision is inferred from the sidecar. let output = fixture.cli(&["patch", "update", "xhci-series"]); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); assert_eq!( String::from_utf8_lossy(&output.stdout).trim(), dir.display().to_string() ); let after = listing(&dir); assert_eq!( after.len(), before.len(), "the old revision was left beside the new one: {after:?}" ); assert!( patches(&dir).iter().all(|name| name.starts_with("v2-")), "every patch moves together: {after:?}" ); assert!( after .iter() .any(|name| name == "v2-0000-cover-letter.patch") ); assert!(read(&dir, "series.toml").contains("version = 2")); assert!( read(&dir, "v2-0000-cover-letter.patch").contains("This replaces the v1 series"), "a re-roll threw the blurb away" ); // The same revision again is a no-op, which is what keeps a double run // from producing a v3. let again = fixture.cli(&["patch", "update", "xhci-series", "-v", "2"]); assert!(again.status.success()); assert_eq!( String::from_utf8_lossy(&again.stdout).trim(), dir.display().to_string() ); let unchanged = listing(&dir); assert_eq!(unchanged, after, "a second run at v2 changed the directory"); assert!( !unchanged.iter().any(|name| name.starts_with("v3-")), "{unchanged:?}" ); // And the re-rolled set still sends, with the cover letter first. let sent = fixture.cli(&["patch", "submit", "xhci-series", "--dry-run"]); assert!( sent.status.success(), "{}", String::from_utf8_lossy(&sent.stderr) ); assert!(String::from_utf8_lossy(&sent.stdout).contains("Dry-OK")); } #[test] fn a_rerolled_series_holds_exactly_one_revision() { let fixture = fixture_with_two_commits(); let dir = create_series(&fixture, &["--cover-letter", BLURB]); // Straight to v3, so the files that must go are the unversioned ones. assert!( fixture .cli(&["patch", "update", "xhci-series", "-v", "3"]) .status .success() ); assert!( fixture .cli(&["patch", "update", "xhci-series", "-v", "4"]) .status .success() ); let names = patches(&dir); assert_eq!(names.len(), 3, "{names:?}"); assert!( names.iter().all(|name| name.starts_with("v4-")), "{names:?}" ); } #[test] fn series_reroll_does_not_pick_up_a_commit_that_landed_after_create() { // The range a series was created for is the range it re-rolls. Rebuilding // it as `base..HEAD` would quietly mail commits nobody asked to send. let fixture = fixture_with_two_commits(); let dir = create_series(&fixture, &["--cover-letter", BLURB]); assert_eq!(patches(&dir).len(), 3); fixture.write( "drivers/foo/bar.c", "static int foo(int a)\n{\n\treturn a + 3;\n}\n", ); fixture.commit_all("foo: return a + 3"); let output = fixture.cli(&["patch", "update", "xhci-series"]); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); let names = patches(&dir); assert_eq!( names.len(), 3, "a commit that landed after create joined the set: {names:?}" ); assert!( !names.iter().any(|name| name.contains("return-a-3")), "{names:?}" ); assert_eq!( read(&dir, "series.toml").matches("version = 2").count(), 1, "{}", read(&dir, "series.toml") ); } #[test] fn series_reroll_tolerates_a_patch_that_is_already_gone() { // A missing file is the state the re-roll wanted, not a failure it should // report forever while leaving the sidecar describing a revision that is // no longer on disk. let fixture = fixture_with_two_commits(); let dir = create_series(&fixture, &["--cover-letter", BLURB]); let doomed = patches(&dir) .into_iter() .find(|name| name.starts_with("0002-")) .expect("the second patch"); std::fs::remove_file(dir.join(&doomed)).expect("remove a patch by hand"); let output = fixture.cli(&["patch", "update", "xhci-series"]); assert!( output.status.success(), "a stale name stopped the re-roll: {}", String::from_utf8_lossy(&output.stderr) ); let names = patches(&dir); assert_eq!(names.len(), 3, "{names:?}"); assert!( names.iter().all(|name| name.starts_with("v2-")), "{names:?}" ); assert!(read(&dir, "series.toml").contains("version = 2")); // And it is ready to send, which is the thing the old failure made // impossible: the sidecar and the directory agree again. let sent = fixture.cli(&["patch", "submit", "xhci-series", "--dry-run"]); assert!( sent.status.success(), "{}", String::from_utf8_lossy(&sent.stderr) ); } #[test] fn series_create_refuses_an_empty_blurb_before_it_writes_anything() { let fixture = fixture_with_two_commits(); let output = fixture.cli(&[ "patch", "create", "xhci-series", "--range", "master..HEAD", "--cover-letter", " ", ]); assert_eq!(output.status.code(), Some(1)); assert!( String::from_utf8_lossy(&output.stderr).contains("blurb is empty"), "{}", String::from_utf8_lossy(&output.stderr) ); let dir = fixture.home().join(".spectral/patches/xhci-series"); assert!( !dir.exists(), "files with no sidecar beside them were left behind: {:?}", listing(&dir) ); } #[test] fn a_directory_that_is_not_a_series_still_sends_its_patches() { // No sidecar: the directory is read as a pile of patches, in name order, // which is the order format-patch numbered them in. let fixture = fixture_with_two_commits(); let dir = fixture.home().join("hand-made"); std::fs::create_dir_all(&dir).expect("create the directory"); fixture.git(&[ "format-patch", "-o", dir.to_str().expect("the directory path"), "master..HEAD", ]); let output = fixture.cli(&[ "patch", "submit", dir.to_str().expect("the directory path"), "--dry-run", ]); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8_lossy(&output.stdout); assert!( stdout.contains("0001-") && stdout.contains("0002-"), "{stdout}" ); } #[test] fn a_series_wants_a_range_and_a_range_that_is_not_one_is_named() { let fixture = fixture_with_two_commits(); let not_a_range = fixture.cli(&["patch", "create", "x", "--range", "master"]); assert_eq!(not_a_range.status.code(), Some(1)); let stderr = String::from_utf8_lossy(¬_a_range.stderr); assert!(stderr.contains("is not a range"), "{stderr}"); let orphan_blurb = fixture.cli(&["patch", "create", "x", "--cover-letter", "hi"]); assert_eq!(orphan_blurb.status.code(), Some(1)); assert!( String::from_utf8_lossy(&orphan_blurb.stderr).contains("belongs to a series"), "{}", String::from_utf8_lossy(&orphan_blurb.stderr) ); } #[test] fn create_needs_a_name_when_no_series_range_is_given() { let fixture = fixture_with_two_commits(); let output = fixture.cli(&["patch", "create"]); assert_eq!(output.status.code(), Some(1)); assert!( String::from_utf8_lossy(&output.stderr).contains("wants a NAME"), "{}", String::from_utf8_lossy(&output.stderr) ); } #[test] fn a_series_with_no_name_lands_in_the_patch_directory_itself() { // NAME names the series directory, so leaving it off means the patch // directory is the series directory. let fixture = fixture_with_two_commits(); let output = fixture.cli(&[ "patch", "create", "--range", "master..HEAD", "--cover-letter", BLURB, ]); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); let patches_dir = fixture.home().join(".spectral/patches"); assert_eq!( String::from_utf8_lossy(&output.stdout).trim(), patches_dir.display().to_string() ); assert!(patches_dir.join("series.toml").is_file()); assert!(patches_dir.join("0000-cover-letter.patch").is_file()); }