patch: fill in the loop verbs, and test them

Every patch verb outside the series work is real. check reads the working
tree through checkpatch's stdin, which is the only way it can read one,
since --git with no revision dies; format puts --fix-inplace's output back
into the tree after setting the uncommitted change aside, and restores it
if the fix does not apply; commit and create sit on git; create writes a
mail-formatted patch, because git send-email refuses a bare diff with "No
subject line"; submit splits get_maintainer.pl's roles into To and Cc,
prints the command line it is about to run, and only sends when --dry-run
is off; update re-rolls a patch by renaming it and does nothing at the
revision it is already at.

git.rs grows the plumbing those verbs stand on: diff_unstaged,
format_patch, apply, commit, rev_parse. apply feeds the patch in on stdin,
so a fixed patch stays in memory until it is known to apply.

tests/ holds a fixture kernel tree, so the suite needs no kernel tree, no
network, and no configured git send-email. The two tests that do want the
host's own checkpatch and get_maintainer are ignored unless asked for, and
one of them earned its place immediately: checkpatch's --file means "this
argument is source code", so checking a patch with it reported a dirty
patch as clean, 8004 lines and no errors. Patchfile mode is checkpatch's
default and the path now stands alone.

patch check also takes --rev, which is how a committed range gets checked.

Checked with cargo fmt --check, cargo clippy --all-targets -- -D warnings,
cargo test, and cargo doc --no-deps.
This commit is contained in:
2026-09-16 18:41:51 -04:00
parent 33161f2ae4
commit 97664806b7
11 changed files with 2173 additions and 102 deletions
+82
View File
@@ -0,0 +1,82 @@
//! The command surface parses, and a bad invocation is a usage error rather
//! than a panic.
mod common;
use common::Fixture;
#[test]
fn the_top_level_help_names_both_command_groups() {
let fixture = Fixture::bare();
let output = fixture.cli(&["--help"]);
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("kernel"), "{stdout}");
assert!(stdout.contains("patch"), "{stdout}");
}
#[test]
fn every_verb_has_help_of_its_own() {
let fixture = Fixture::bare();
for verb in [
&["kernel", "quest"][..],
&["kernel", "test"],
&["patch", "check"],
&["patch", "format"],
&["patch", "commit"],
&["patch", "create"],
&["patch", "submit"],
&["patch", "update"],
] {
let mut args = verb.to_vec();
args.push("--help");
let output = fixture.cli(&args);
assert!(
output.status.success(),
"{} --help failed: {}",
verb.join(" "),
String::from_utf8_lossy(&output.stderr)
);
assert!(
!String::from_utf8_lossy(&output.stdout).is_empty(),
"{} --help printed nothing",
verb.join(" ")
);
}
}
#[test]
fn an_unknown_subcommand_exits_two() {
let fixture = Fixture::bare();
let output = fixture.cli(&["frobnicate"]);
assert_eq!(output.status.code(), Some(2));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("Usage"), "{stderr}");
}
#[test]
fn a_leaf_missing_its_argument_exits_two() {
let fixture = Fixture::bare();
let output = fixture.cli(&["patch", "commit"]);
assert_eq!(output.status.code(), Some(2));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("MESSAGE"), "{stderr}");
}
#[test]
fn a_verb_that_is_still_a_stub_panics_where_it_says_it_does() {
// `kernel quest` is P5 and `kernel test` is P6. Until they land, this is
// the proof that the dispatch is wired end to end: exit 101 with the
// panic's own location in it.
let fixture = Fixture::kernel_tree();
let output = fixture.cli(&["kernel", "quest"]);
assert_eq!(output.status.code(), Some(101));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("kernel/quest.rs"), "{stderr}");
}
+249
View File
@@ -0,0 +1,249 @@
//! Fixture helpers shared by the CLI tests.
//!
//! A test never touches the machine it runs on. `HOME`, `SPECTRAL_KERNEL`, and
//! git's global configuration all point into a temp directory that deletes
//! itself when the test ends, so `~/.spectral` is never written and the
//! developer's git config never leaks in.
//!
//! The kernel tree, when a test wants one, is a real git repository holding the
//! two scripts spectral shells out to, a MAINTAINERS entry, and one commit.
#![allow(dead_code)] // each test binary uses a different part of this set
use std::os::unix::fs::PermissionsExt as _;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicU32, Ordering};
/// The checkpatch stand-in.
///
/// The real one is a kernel script whose output moves with the kernel version,
/// which is not something a test can assert on. This records how it was called,
/// then says what the test asked for through the environment:
/// `FIXTURE_SUMMARY` is printed as checkpatch's summary line, `FIXTURE_EXIT` is
/// the exit status, and `FIXTURE_RECORD` names the file the call is noted in.
const CHECKPATCH: &str = r#"#!/bin/sh
: "${FIXTURE_RECORD:=.checkpatch-call}"
{
echo "argv: $*"
echo "cwd: $(pwd)"
cat
} >> "$FIXTURE_RECORD"
[ -n "$FIXTURE_SUMMARY" ] && printf '%s\n' "$FIXTURE_SUMMARY"
exit "${FIXTURE_EXIT:-0}"
"#;
/// The get_maintainer stand-in, for the same reason.
///
/// One line per role the real script emits, including a git signer whose
/// address is already on the `To:` line, so the dedup is exercised.
const GET_MAINTAINER: &str = r#"#!/bin/sh
echo "argv: $*" >> .getmaintainer-call
echo "Fixture Maintainer <[email protected]> (maintainer:FIXTURE DRIVER)"
echo "[email protected] (open list:FIXTURE DRIVER)"
echo "Fixture Reviewer <[email protected]> (reviewer:FIXTURE REVIEWERS)"
echo "[email protected] (open list:FIXTURE REVIEWERS)"
echo "\"GitAuthor: Fixture\" <[email protected]> (authored,added_lines)"
"#;
/// The file `return a;` is committed as, and the edit most tests start from.
pub const COMMITTED: &str = "static int foo(int a)\n{\n\treturn a;\n}\n";
pub const EDITED: &str = "static int foo(int a)\n{\n\treturn a + 1;\n}\n";
/// A temp directory that removes itself when the test ends.
#[derive(Debug)]
pub struct TempDir {
path: PathBuf,
}
impl TempDir {
fn new(label: &str) -> Self {
static NEXT: AtomicU32 = AtomicU32::new(0);
let path = std::env::temp_dir().join(format!(
"spectral-test-{}-{label}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir_all(&path).expect("create the temp directory");
Self { path }
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn join(&self, rel: &str) -> PathBuf {
self.path.join(rel)
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
/// A throwaway home directory, and a kernel tree when the test wants one.
pub struct Fixture {
home: TempDir,
root: TempDir,
}
impl Fixture {
/// A home with nothing in it but a git identity.
pub fn bare() -> Self {
let fixture = Self {
home: TempDir::new("home"),
root: TempDir::new("root"),
};
std::fs::write(
fixture.home.join("gitconfig"),
"[user]\n\tname = Fixture\n\temail = [email protected]\n",
)
.expect("write the fixture git config");
fixture
}
/// A git repository shaped like a kernel tree.
pub fn kernel_tree() -> Self {
let fixture = Self::bare();
// Pinned: `create` defaults to `--base master`, the kernel's branch,
// and the fixture should not inherit whatever a machine's git defaults
// to for a new repository.
fixture.git(&["init", "--quiet", "--initial-branch=master"]);
fixture.git(&["config", "user.name", "Fixture"]);
fixture.git(&["config", "user.email", "[email protected]"]);
fixture.write(
"MAINTAINERS",
"FIXTURE DRIVER\nM:\tFixture Maintainer <[email protected]>\nL:\t[email protected]\nS:\tMaintained\nF:\tdrivers/foo/*\n\nFIXTURE REVIEWERS\nR:\tFixture Reviewer <[email protected]>\nL:\t[email protected]\nS:\tOdd Fixes\nF:\tdrivers/foo/*\n",
);
fixture.write("drivers/foo/bar.c", COMMITTED);
fixture.write_script("scripts/checkpatch.pl", CHECKPATCH);
fixture.write_script("scripts/get_maintainer.pl", GET_MAINTAINER);
fixture.commit_all("fixture: add bar");
fixture
}
/// The directory the tests treat as `$HOME`.
pub fn home(&self) -> &Path {
self.home.path()
}
/// The kernel tree, which is also the working directory the binary runs in.
pub fn tree(&self) -> &Path {
self.root.path()
}
pub fn join(&self, rel: &str) -> PathBuf {
self.root.join(rel)
}
/// Write a file into the tree, making its directory first.
pub fn write(&self, rel: &str, contents: &str) {
let path = self.join(rel);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("create the file's directory");
}
std::fs::write(&path, contents).expect("write the file");
}
/// Write an executable script into the tree.
pub fn write_script(&self, rel: &str, body: &str) {
self.write(rel, body);
let path = self.join(rel);
let mut permissions = std::fs::metadata(&path)
.expect("stat the script")
.permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(&path, permissions).expect("make the script executable");
}
pub fn read(&self, rel: &str) -> String {
std::fs::read_to_string(self.join(rel)).expect("read the file")
}
/// Run git in the tree and return its trimmed stdout.
pub fn git(&self, args: &[&str]) -> String {
let output = self.git_command(args).output().expect("run git");
assert!(
output.status.success(),
"git {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8_lossy(&output.stdout)
.trim_end()
.to_owned()
}
/// Run git expecting a failure, and hand back its stderr.
///
/// Used to pin the things spectral works around, such as send-email
/// refusing a bare diff.
pub fn git_must_fail(&self, args: &[&str]) -> String {
let output = self.git_command(args).output().expect("run git");
assert!(
!output.status.success(),
"git {} was supposed to fail",
args.join(" ")
);
String::from_utf8_lossy(&output.stderr)
.trim_end()
.to_owned()
}
pub fn commit_all(&self, message: &str) {
self.git(&["add", "-A"]);
self.git(&["commit", "--quiet", "-m", message]);
}
/// Do the work on a branch of its own, the way the loop expects: `create`
/// diffs the branch against `master`, so committing straight to `master`
/// leaves it nothing to send.
pub fn work_on_a_branch(&self) {
self.git(&["checkout", "--quiet", "-b", "work"]);
}
/// Run the built binary with the fixture's environment.
pub fn cli(&self, args: &[&str]) -> Output {
self.cli_env(args, &[])
}
/// Run the built binary with extra environment on top of the fixture's.
pub fn cli_env(&self, args: &[&str], env: &[(&str, &str)]) -> Output {
let mut command = Command::new(env!("CARGO_BIN_EXE_spectral"));
command
.args(args)
.current_dir(self.root.path())
.env_clear()
.env("PATH", std::env::var("PATH").unwrap_or_default())
.env("HOME", self.home.path())
.env("SPECTRAL_KERNEL", self.root.path())
.env("GIT_CONFIG_GLOBAL", self.home.join("gitconfig"))
.env("GIT_CONFIG_SYSTEM", "/dev/null");
for (key, value) in env {
command.env(key, value);
}
command.output().expect("run spectral")
}
fn git_command(&self, args: &[&str]) -> Command {
let mut command = Command::new("git");
command
.arg("-C")
.arg(self.root.path())
.args(args)
.env("HOME", self.home.path())
.env("GIT_CONFIG_GLOBAL", self.home.join("gitconfig"))
.env("GIT_CONFIG_SYSTEM", "/dev/null");
command
}
}
+708
View File
@@ -0,0 +1,708 @@
//! The patch verbs, driven through the built binary against a fixture tree.
mod common;
use common::Fixture;
/// Every stub records how it was called, so a test can assert the arguments
/// spectral really passed and see the diff that came in on stdin.
const RECORD: &str = r#": "${FIXTURE_RECORD:=.checkpatch-call}"
{
echo "argv: $*"
echo "cwd: $(pwd)"
cat
} >> "$FIXTURE_RECORD"
"#;
/// Build a checkpatch stand-in from the part that differs between tests.
fn stub(body: &str) -> String {
format!("#!/bin/sh\n{RECORD}{body}")
}
/// Rewrites the patch's added line, the way a real fix pass does, then reports
/// clean.
const FIXER: &str = r#"if [ "$2" = "--fix-inplace" ]; then
sed -i 's/return a + 1/return a + 2/' "$3"
fi
echo "total: 0 errors, 0 warnings, 9 lines checked"
exit 0
"#;
/// Leaves a mark in the patch file it is given, for the case where nothing has
/// to be applied to a tree afterwards.
const TOUCHER: &str = r#"if [ "$2" = "--fix-inplace" ]; then
printf 'FIXED\n' >> "$3"
fi
echo "total: 0 errors, 0 warnings, 3 lines checked"
exit 0
"#;
/// A fix that is useless, for the path that has to undo one.
const RUINER: &str = r#"if [ "$2" = "--fix-inplace" ]; then
printf 'this is not a patch at all\n' > "$3"
fi
echo "total: 1 errors, 0 warnings, 3 lines checked"
exit 1
"#;
#[test]
fn commit_signs_off_and_keeps_the_message() {
let fixture = Fixture::kernel_tree();
fixture.write("drivers/foo/bar.c", common::EDITED);
fixture.git(&["add", "-A"]);
let output = fixture.cli(&["patch", "commit", "foo: return a + 1", "--signoff"]);
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let message = fixture.git(&["log", "-1", "--pretty=%B"]);
assert!(message.contains("foo: return a + 1"), "{message}");
assert!(
message.contains("Signed-off-by: Fixture <[email protected]>"),
"{message}"
);
}
#[test]
fn commit_amend_replaces_the_previous_commit() {
let fixture = Fixture::kernel_tree();
fixture.write("drivers/foo/bar.c", common::EDITED);
fixture.git(&["add", "-A"]);
let output = fixture.cli(&["patch", "commit", "foo: reworded", "--amend"]);
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(fixture.git(&["rev-list", "--count", "HEAD"]), "1");
assert!(
fixture
.git(&["log", "-1", "--pretty=%s"])
.contains("reworded")
);
}
#[test]
fn create_writes_a_patch_that_send_email_accepts() {
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");
let output = fixture.cli(&["patch", "create", "000-foo"]);
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let printed = String::from_utf8_lossy(&output.stdout).trim().to_owned();
assert_eq!(
printed,
fixture
.home()
.join(".spectral/patches/000-foo.patch")
.display()
.to_string()
);
let patch = std::fs::read_to_string(&printed).expect("the patch create wrote");
assert!(patch.starts_with("From "), "{patch}");
assert!(
patch.contains("Subject: [PATCH] foo: return a + 1"),
"{patch}"
);
assert!(patch.contains("return a + 1;"), "{patch}");
let sent = fixture.git(&[
"send-email",
"--dry-run",
"--confirm=never",
"--to",
"[email protected]",
&printed,
]);
assert!(sent.contains("Dry-OK"), "{sent}");
}
#[test]
fn a_bare_diff_is_refused_by_send_email() {
// This is why `create` goes through format-patch, so the failure is pinned
// rather than rediscovered.
let fixture = Fixture::kernel_tree();
fixture.write("drivers/foo/bar.c", common::EDITED);
let diff = fixture.git(&["diff"]);
let path = fixture.home().join("bare.patch");
std::fs::write(&path, format!("{diff}\n")).expect("write the bare diff");
let stderr = fixture.git_must_fail(&[
"send-email",
"--dry-run",
"--confirm=never",
"--to",
"[email protected]",
path.to_str().expect("the patch path"),
]);
assert!(stderr.contains("No subject line"), "{stderr}");
}
#[test]
fn create_names_a_base_that_does_not_resolve() {
let fixture = Fixture::kernel_tree();
let output = fixture.cli(&["patch", "create", "000-foo", "--base", "not-a-branch"]);
assert_eq!(output.status.code(), Some(1));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("no revision `not-a-branch`"), "{stderr}");
}
#[test]
fn create_refuses_a_range_that_holds_more_than_one_commit() {
let fixture = Fixture::kernel_tree();
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");
let output = fixture.cli(&["patch", "create", "000-foo", "--base", "HEAD~2"]);
assert_eq!(output.status.code(), Some(1));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("holds 2 commits"), "{stderr}");
assert!(
stderr.contains("series support is not built yet"),
"{stderr}"
);
}
#[test]
fn create_refuses_when_there_is_nothing_to_send() {
let fixture = Fixture::kernel_tree();
let output = fixture.cli(&["patch", "create", "000-foo", "--base", "HEAD"]);
assert_eq!(output.status.code(), Some(1));
assert!(String::from_utf8_lossy(&output.stderr).contains("nothing to send"));
}
#[test]
fn check_pipes_the_working_tree_diff_into_checkpatch_and_fails_on_errors() {
let fixture = Fixture::kernel_tree();
fixture.write("drivers/foo/bar.c", common::EDITED);
let output = fixture.cli_env(
&["patch", "check"],
&[
(
"FIXTURE_SUMMARY",
"total: 4 errors, 2 warnings, 9 lines checked",
),
("FIXTURE_EXIT", "1"),
],
);
assert_eq!(output.status.code(), Some(1));
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("total: 4 errors, 2 warnings, 9 lines checked"),
"{stdout}"
);
// The diff really went in, rather than a revision being named.
let call = fixture.read(".checkpatch-call");
assert!(call.contains("argv: --no-tree -"), "{call}");
assert!(call.contains("+ return a + 1;"), "{call}");
assert!(
call.contains(&format!("cwd: {}", fixture.tree().display())),
"{call}"
);
}
#[test]
fn warnings_alone_do_not_fail_the_command() {
let fixture = Fixture::kernel_tree();
fixture.write("drivers/foo/bar.c", common::EDITED);
let output = fixture.cli_env(
&["patch", "check"],
&[(
"FIXTURE_SUMMARY",
"total: 0 errors, 3 warnings, 9 lines checked",
)],
);
assert_eq!(output.status.code(), Some(0));
assert!(String::from_utf8_lossy(&output.stdout).contains("0 errors, 3 warnings"));
}
#[test]
fn a_run_with_no_total_line_counts_as_clean() {
// What a clean run looks like under checkpatch's own --terse, where the
// summary line is suppressed entirely.
let fixture = Fixture::kernel_tree();
fixture.write("drivers/foo/bar.c", common::EDITED);
let output = fixture.cli(&["patch", "check"]);
assert_eq!(output.status.code(), Some(0));
assert!(String::from_utf8_lossy(&output.stdout).trim().is_empty());
}
#[test]
fn a_patch_file_and_a_revision_go_in_as_themselves() {
let fixture = Fixture::kernel_tree();
let patch = fixture.home().join("thing.patch");
std::fs::write(&patch, "--- a/drivers/foo/bar.c\n+++ b/drivers/foo/bar.c\n").expect("write");
let by_file = fixture.cli(&[
"patch",
"check",
patch.to_str().expect("the patch path"),
"--strict",
]);
assert!(by_file.status.success());
let call = fixture.read(".checkpatch-call");
assert!(call.contains("--strict"), "{call}");
assert!(
!call.contains("--file"),
"checkpatch reads --file as a source file, not as a patch: {call}"
);
assert!(call.contains("thing.patch"), "{call}");
let by_rev = fixture.cli(&["patch", "check", "--rev", "HEAD"]);
assert!(by_rev.status.success());
let call = fixture.read(".checkpatch-call");
assert!(call.contains("--git HEAD"), "{call}");
}
#[test]
fn a_patch_file_that_is_not_there_is_named_as_such() {
let fixture = Fixture::kernel_tree();
let output = fixture.cli(&["patch", "check", "nope.patch"]);
assert_eq!(output.status.code(), Some(1));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("no patch named `nope.patch`"), "{stderr}");
}
#[test]
fn a_missing_tree_names_the_variable_that_would_have_set_it() {
let fixture = Fixture::bare();
let output = fixture.cli_env(
&["patch", "check"],
&[("SPECTRAL_KERNEL", "/nonexistent/linux")],
);
assert_eq!(output.status.code(), Some(1));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("no kernel tree at `/nonexistent/linux`"),
"{stderr}"
);
assert!(stderr.contains("SPECTRAL_KERNEL"), "{stderr}");
}
#[test]
fn format_rewrites_a_patch_file_in_place_and_checks_it_again() {
let fixture = Fixture::kernel_tree();
fixture.write_script("scripts/checkpatch.pl", &stub(TOUCHER));
let patch = fixture.home().join("thing.patch");
std::fs::write(&patch, "--- a/x\n+++ b/x\n").expect("write the patch");
let output = fixture.cli(&["patch", "format", patch.to_str().expect("the patch path")]);
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert!(
std::fs::read_to_string(&patch)
.expect("the patch")
.ends_with("FIXED\n")
);
let call = fixture.read(".checkpatch-call");
assert!(call.contains("argv: --no-tree --fix-inplace "), "{call}");
assert!(call.contains("thing.patch"), "{call}");
assert!(!call.contains("--file"), "{call}");
}
#[test]
fn format_applies_the_fix_to_the_working_tree() {
let fixture = Fixture::kernel_tree();
fixture.write_script("scripts/checkpatch.pl", &stub(FIXER));
fixture.commit_all("fixture: use the fixing checkpatch");
fixture.write("drivers/foo/bar.c", common::EDITED);
let output = fixture.cli(&["patch", "format"]);
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let file = fixture.read("drivers/foo/bar.c");
assert!(
file.contains("return a + 2;"),
"the fix did not land: {file}"
);
assert!(fixture.git(&["diff"]).contains("+ return a + 2;"));
let call = fixture.read(".checkpatch-call");
assert!(call.contains("argv: --no-tree --fix-inplace "), "{call}");
assert!(!call.contains("--file"), "{call}");
assert!(call.contains("argv: --no-tree -"), "{call}");
}
#[test]
fn a_fix_that_does_not_apply_leaves_the_tree_alone() {
let fixture = Fixture::kernel_tree();
fixture.write_script("scripts/checkpatch.pl", &stub(RUINER));
fixture.commit_all("fixture: use the useless checkpatch");
fixture.write("drivers/foo/bar.c", common::EDITED);
let output = fixture.cli(&["patch", "format"]);
assert_eq!(output.status.code(), Some(1));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("does not apply"), "{stderr}");
assert!(
fixture.read("drivers/foo/bar.c").contains("return a + 1;"),
"the uncommitted change was not put back"
);
}
#[test]
fn submit_dry_run_shows_the_recipients_and_the_command() {
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");
let created = fixture.cli(&["patch", "create", "000-foo"]);
assert!(
created.status.success(),
"{}",
String::from_utf8_lossy(&created.stderr)
);
let patch = fixture.home().join(".spectral/patches/000-foo.patch");
let output = fixture.cli(&[
"patch",
"submit",
patch.to_str().expect("the patch path"),
"--dry-run",
]);
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("Files: drivers/foo/bar.c"),
"the lookup should say what it ran against: {stdout}"
);
assert!(
stdout.contains("To: Fixture Maintainer <[email protected]>"),
"{stdout}"
);
assert!(stdout.contains("To: [email protected]"), "{stdout}");
assert!(
stdout.contains("Cc: Fixture Reviewer <[email protected]>"),
"{stdout}"
);
assert!(stdout.contains("Cc: [email protected]"), "{stdout}");
assert!(
!stdout.contains("Cc: Fixture Maintainer"),
"an address on the To line needs no courtesy copy: {stdout}"
);
// The command line is the thing the README tells a reader to look at.
assert!(
stdout.contains("send-email --dry-run --confirm=never"),
"{stdout}"
);
assert!(
stdout.contains("--to 'Fixture Maintainer <[email protected]>'"),
"{stdout}"
);
assert!(stdout.contains("Dry-OK"), "send-email's own plan: {stdout}");
// And the recipients came from the tree's own script, asked the way the
// fixture tree needs to be asked.
let call = fixture.read(".getmaintainer-call");
assert!(
call.contains("--no-tree --git --roles --no-rolestats --pattern-depth=0"),
"{call}"
);
assert!(call.contains("000-foo.patch"), "{call}");
}
#[test]
fn submit_puts_extra_ccs_on_the_cc_line_once_and_skips_anyone_on_to() {
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");
let created = fixture.cli(&["patch", "create", "000-foo"]);
assert!(created.status.success());
let patch = fixture.home().join(".spectral/patches/000-foo.patch");
let output = fixture.cli(&[
"patch",
"submit",
patch.to_str().expect("the patch path"),
"--dry-run",
"--cc",
"[email protected]",
"--cc",
"[email protected]",
"--cc",
"[email protected]",
]);
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
// Only the block above the command line is ours; send-email echoes the
// same addresses back in its own plan.
let ours: Vec<&str> = stdout
.lines()
.take_while(|line| !line.contains("send-email --dry-run"))
.collect();
let cc_lines: Vec<&str> = ours
.iter()
.copied()
.filter(|line| line.starts_with("Cc: "))
.collect();
assert_eq!(cc_lines.len(), 3, "{stdout}");
assert_eq!(cc_lines[2], "Cc: [email protected]");
assert_eq!(
stdout.matches("--cc [email protected]").count(),
1,
"{stdout}"
);
assert!(
!stdout.contains("Cc: [email protected]"),
"the list is already on the To line: {stdout}"
);
}
#[test]
fn submit_threads_a_reroll_off_the_patch_it_replaces() {
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");
let created = fixture.cli(&["patch", "create", "000-foo"]);
assert!(created.status.success());
let patch = fixture.home().join(".spectral/patches/000-foo.patch");
let output = fixture.cli(&[
"patch",
"submit",
patch.to_str().expect("the patch path"),
"--dry-run",
"--in-reply-to",
"<[email protected]>",
]);
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("--in-reply-to <[email protected]>"),
"{stdout}"
);
assert!(stdout.contains("In-Reply-To: <[email protected]>"), "{stdout}");
}
#[test]
fn submit_refuses_a_template_cover_letter_instead_of_swallowing_it() {
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.git(&[
"format-patch",
"--cover-letter",
"-o",
"out",
"master..HEAD",
]);
let cover = fixture.join("out/0000-cover-letter.patch");
let output = fixture.cli(&[
"patch",
"submit",
cover.to_str().expect("the cover letter path"),
"--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 submit_names_a_patch_that_is_not_there() {
let fixture = Fixture::kernel_tree();
let output = fixture.cli(&["patch", "submit", "nope.patch", "--dry-run"]);
assert_eq!(output.status.code(), Some(1));
assert!(String::from_utf8_lossy(&output.stderr).contains("no patch named `nope.patch`"));
}
#[test]
fn update_renames_a_patch_to_the_next_revision_without_rewriting_it() {
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");
assert!(
fixture
.cli(&["patch", "create", "000-foo"])
.status
.success()
);
let patches = fixture.home().join(".spectral/patches");
let before = std::fs::read(patches.join("000-foo.patch")).expect("the created patch");
// Named the way the README's examples name it: no path in front.
let output = fixture.cli(&["patch", "update", "000-foo.patch"]);
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let printed = String::from_utf8_lossy(&output.stdout).trim().to_owned();
assert_eq!(
printed,
patches.join("v1-000-foo.patch").display().to_string()
);
assert_eq!(
std::fs::read(patches.join("v1-000-foo.patch")).expect("the renamed patch"),
before,
"a re-roll moves the file, it does not rewrite it"
);
assert!(!patches.join("000-foo.patch").exists());
}
#[test]
fn update_infers_the_next_revision_and_does_nothing_at_the_same_one() {
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");
assert!(
fixture
.cli(&["patch", "create", "000-foo"])
.status
.success()
);
let patches = fixture.home().join(".spectral/patches");
assert!(
fixture
.cli(&["patch", "update", "000-foo.patch"])
.status
.success()
);
let to_v2 = fixture.cli(&["patch", "update", "v1-000-foo.patch"]);
assert!(to_v2.status.success());
assert_eq!(
String::from_utf8_lossy(&to_v2.stdout).trim(),
patches.join("v2-000-foo.patch").display().to_string()
);
let before = std::fs::read(patches.join("v2-000-foo.patch")).expect("the v2 patch");
let again = fixture.cli(&["patch", "update", "v2-000-foo.patch", "-v", "2"]);
assert!(again.status.success());
assert_eq!(
String::from_utf8_lossy(&again.stdout).trim(),
patches.join("v2-000-foo.patch").display().to_string()
);
assert_eq!(
std::fs::read(patches.join("v2-000-foo.patch")).expect("the v2 patch"),
before
);
assert!(!patches.join("v3-000-foo.patch").exists());
}
#[test]
fn update_refuses_to_land_on_a_patch_that_is_already_there() {
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");
assert!(
fixture
.cli(&["patch", "create", "000-foo"])
.status
.success()
);
assert!(
fixture
.cli(&["patch", "update", "000-foo.patch", "-v", "1"])
.status
.success()
);
assert!(
fixture
.cli(&["patch", "create", "000-foo"])
.status
.success()
);
let output = fixture.cli(&["patch", "update", "000-foo.patch", "-v", "1"]);
assert_eq!(output.status.code(), Some(1));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("already exists"), "{stderr}");
}
#[test]
fn a_patch_name_resolves_without_its_path() {
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");
assert!(
fixture
.cli(&["patch", "create", "000-foo"])
.status
.success()
);
// Straight from `create` into `submit`, by name, as the README shows.
let output = fixture.cli(&["patch", "submit", "000-foo.patch", "--dry-run"]);
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("To: Fixture Maintainer <[email protected]>"),
"{stdout}"
);
assert!(stdout.contains("Dry-OK"), "{stdout}");
}
+124
View File
@@ -0,0 +1,124 @@
//! The real tools, when the machine has them.
//!
//! Everything else in `tests/` drives stubs, because checkpatch's output moves
//! with the kernel version and a test cannot assert on that. These tests are the
//! other half: they run the host's own scripts so a kernel-version change to a
//! summary line, an argument, or an exit code shows up here rather than in
//! someone's patch workflow.
//!
//! They are ignored by default and need both variables:
//!
//! ```console
//! $ SPECTRAL_REAL_CHECKPATCH=1 SPECTRAL_REAL_TREE=/usr/src/linux \
//! cargo test --locked -- --ignored real_
//! ```
mod common;
use common::Fixture;
/// A change checkpatch has opinions about, whatever kernel it comes from.
const BAD: &str =
"static int foo(int a) {\n\tint b = a + 1;\n\tif (a>0)\n\t\tb += 2;\n\treturn b;\n}\n";
#[test]
#[ignore = "needs SPECTRAL_REAL_CHECKPATCH=1 and SPECTRAL_REAL_TREE=/path/to/linux"]
fn real_checkpatch_reads_all_three_kinds_of_target() {
let fixture = real_fixture();
fixture.write("drivers/foo/bar.c", BAD);
// The working tree goes in on stdin, because `--git` with no revision
// dies with "no git commits after extraction".
let working_tree = fixture.cli(&["patch", "check"]);
let stdout = String::from_utf8_lossy(&working_tree.stdout).into_owned();
assert_eq!(working_tree.status.code(), Some(1), "{stdout}");
assert!(stdout.contains("total:"), "{stdout}");
assert!(stdout.contains("ERROR"), "{stdout}");
// A revision goes through --git, and checkpatch names the commit it read.
let by_rev = fixture.cli(&["patch", "check", "--rev", "HEAD"]);
let stdout = String::from_utf8_lossy(&by_rev.stdout).into_owned();
assert!(stdout.contains("fixture: add bar"), "{stdout}");
// A patch file goes through --file.
let patch = fixture.home().join("bad.patch");
std::fs::write(&patch, format!("{}\n", fixture.git(&["diff"]))).expect("write the patch");
let by_file = fixture.cli(&["patch", "check", patch.to_str().expect("the patch path")]);
let stdout = String::from_utf8_lossy(&by_file.stdout).into_owned();
assert_eq!(by_file.status.code(), Some(1), "{stdout}");
assert!(stdout.contains("ERROR"), "{stdout}");
}
/// A fixture whose checkpatch is the host's, copied in.
fn real_fixture() -> Fixture {
assert_eq!(
std::env::var("SPECTRAL_REAL_CHECKPATCH").as_deref(),
Ok("1"),
"run this with SPECTRAL_REAL_CHECKPATCH=1"
);
let tree = std::env::var("SPECTRAL_REAL_TREE")
.expect("set SPECTRAL_REAL_TREE to a kernel tree holding scripts/checkpatch.pl");
let fixture = Fixture::kernel_tree();
let script = std::path::Path::new(&tree).join("scripts/checkpatch.pl");
let body = std::fs::read_to_string(&script)
.unwrap_or_else(|error| panic!("read {}: {error}", script.display()));
fixture.write_script("scripts/checkpatch.pl", &body);
fixture
}
#[test]
#[ignore = "needs SPECTRAL_REAL_GETMAINTAINER=1 and SPECTRAL_REAL_TREE=/path/to/linux"]
fn real_get_maintainer_fills_the_to_and_cc_lines() {
assert_eq!(
std::env::var("SPECTRAL_REAL_GETMAINTAINER").as_deref(),
Ok("1"),
"run this with SPECTRAL_REAL_GETMAINTAINER=1"
);
let tree = std::env::var("SPECTRAL_REAL_TREE")
.expect("set SPECTRAL_REAL_TREE to a kernel tree holding scripts/get_maintainer.pl");
let fixture = Fixture::kernel_tree();
let script = std::path::Path::new(&tree).join("scripts/get_maintainer.pl");
let body = std::fs::read_to_string(&script)
.unwrap_or_else(|error| panic!("read {}: {error}", script.display()));
fixture.write_script("scripts/get_maintainer.pl", &body);
fixture.commit_all("fixture: use the real get_maintainer");
fixture.work_on_a_branch();
fixture.write("drivers/foo/bar.c", common::EDITED);
fixture.commit_all("foo: return a + 1");
let created = fixture.cli(&["patch", "create", "000-foo"]);
assert!(
created.status.success(),
"{}",
String::from_utf8_lossy(&created.stderr)
);
let patch = fixture.home().join(".spectral/patches/000-foo.patch");
let output = fixture.cli(&[
"patch",
"submit",
patch.to_str().expect("the patch path"),
"--dry-run",
]);
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
// The script's own words decide the split, so the assertion is about the
// shape rather than about which kernel's MAINTAINERS matched.
assert!(
stdout.contains("To: Fixture Maintainer <[email protected]>"),
"{stdout}"
);
assert!(
stdout.lines().any(|line| line.starts_with("Cc: ")),
"a reviewer and a second list belong on the Cc line: {stdout}"
);
assert!(stdout.contains("Dry-OK"), "{stdout}");
}