kernel quest reads bugzilla's REST API and picks one bug through a seeded xorshift, so a pick is reproducible in a test and varied in a person's hands. kernel test builds under spawn_blocking and boots under qemu with the serial console streamed line by line, and it reports what qemu did instead of flattening a failed boot into a success. patch create writes a whole series when --range is given. git format-patch does the naming and the numbering because that is already its job, the cover letter comes from --cover-letter, and a series.toml beside the patches records the range with both ends as hashes, the revision, the files and the blurb. patch submit takes a directory or several files and sends them in one git send-email invocation, and patch update re-rolls a directory through --reroll-count, deleting the superseded revision only once the new one is complete. doctor runs its checks in the order a first run hits them, prints one line and a fix hint for each, and names the first hard failure in the summary. A missing qemu or a missing mail route warns and keeps exit 0, because --dry-run is where the loop actually stops on a machine without an SMTP route. ~/.config/spectral/config.toml holds the tree path, the patch directory, always-CC addresses and an identity, with the environment winning over the file and the file over the built-in default. spectral init verifies or clones a tree and writes it once: a second run with the same tree says so and writes nothing, and a run that would change an existing answer is refused until --force. tests/ grew a fixture for each of those. The harness controls PATH and git's configuration, and its PATH can hold a git that answers send-email either way, so a host without that support still passes rather than deciding the result. Two review lanes ran over this work and this commit carries their fixes. A re-roll regenerated base..HEAD, so a commit that landed after create would have silently joined the set and been mailed: the range is recorded now and a re-roll regenerates exactly it. A patch file that was already gone made update fail forever while the sidecar described a revision that was not on disk: a missing file is the state the re-roll wanted. An empty blurb aborted after the files were written: it is checked before anything is generated. A tree that is not a git repository was reported as a missing revision. init cloned before it refused. The bugzilla request had no timeout, a signal-ended child printed the bare word "signal", CommandSpawn dropped the io reason, and the printed send-email line quoted only arguments with spaces, so --in-reply-to <id@host> pasted as a redirection. Checked with cargo fmt --all -- --check, cargo clippy --all-targets --locked -- -D warnings, cargo test --locked (96 passed, 4 ignored) and cargo doc --no-deps. The four ignored tests want the host's own checkpatch, get_maintainer and bugzilla, and all four pass when asked for.
465 lines
15 KiB
Rust
465 lines
15 KiB
Rust
//! 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<String> {
|
|
let mut names: Vec<String> = 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<String> {
|
|
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());
|
|
}
|