Files
spectral/tests/series.rs
T
huntedbytheirs 1c668c3c1e kernel, patch, doctor, config: fill in the rest of Now and Next
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.
2026-09-16 21:52:05 -04:00

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(&not_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());
}