use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::mpsc::{RecvTimeoutError, channel};
use std::time::{Duration, Instant};
pub const DIR: &str = ".marver";
pub const INCLUDE: &str = "include";
pub const SETUP: &str = "setup";
pub const TEARDOWN: &str = "teardown";
pub const LOG_FILE: &str = "setup.log";
const SETUP_TIMEOUT: Duration = Duration::from_secs(300);
const TEARDOWN_TIMEOUT: Duration = Duration::from_secs(60);
const REAP_GRACE: Duration = Duration::from_secs(2);
const SETTLE: Duration = Duration::from_millis(50);
const MAX_OUTPUT: usize = 64 * 1024;
const LIMIT: usize = MAX_OUTPUT + 8 * 1024;
#[derive(Debug, Clone, Copy)]
pub struct Context<'a> {
pub repo: &'a Path,
pub repo_name: &'a str,
pub worktree: &'a Path,
pub branch: &'a str,
pub base: &'a str,
pub task_id: i64,
pub workspace: &'a Path,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Script {
Absent,
Ran {
ok: bool,
output: String,
took: Duration,
},
TimedOut { after: Duration, output: String },
Failed(String),
}
impl Script {
pub fn is_quiet(&self) -> bool {
matches!(self, Script::Absent | Script::Ran { ok: true, .. })
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Carried {
pub copied: Vec<PathBuf>,
pub missing: Vec<PathBuf>,
pub failed: Vec<(PathBuf, String)>,
pub refused: Vec<String>,
}
impl Carried {
pub fn is_quiet(&self) -> bool {
self.failed.is_empty() && self.refused.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Prepared {
pub repo_name: String,
pub carried: Carried,
pub setup: Script,
}
impl Prepared {
pub fn is_quiet(&self) -> bool {
self.carried.is_quiet() && self.setup.is_quiet()
}
pub fn summary(&self) -> Option<String> {
match &self.setup {
_ if !self.carried.is_quiet() => {
let mut parts = Vec::new();
for (path, why) in &self.carried.failed {
parts.push(format!("{} ({why})", path.display()));
}
for line in &self.carried.refused {
parts.push(format!("{line} (outside the repo)"));
}
Some(format!(
"{}: could not carry {}",
self.repo_name,
parts.join(", ")
))
}
Script::Ran { ok: false, .. } => {
Some(format!("{}: {DIR}/{SETUP} failed", self.repo_name))
}
Script::TimedOut { after, .. } => Some(format!(
"{}: {DIR}/{SETUP} was killed after {}s",
self.repo_name,
after.as_secs()
)),
Script::Failed(why) => Some(format!(
"{}: could not run {DIR}/{SETUP}: {why}",
self.repo_name
)),
_ => None,
}
}
fn section(&self) -> Option<String> {
if self.is_quiet() && matches!(self.setup, Script::Absent) && self.carried.copied.is_empty()
{
return None;
}
let mut out = format!("# {}\n", self.repo_name);
if !self.carried.copied.is_empty() {
out.push_str(&format!(
"carried {}\n",
self.carried
.copied
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", ")
));
}
for (path, why) in &self.carried.failed {
out.push_str(&format!("failed {}: {why}\n", path.display()));
}
for line in &self.carried.refused {
out.push_str(&format!("refused {line}: outside the repo\n"));
}
match &self.setup {
Script::Absent => {}
Script::Ran { ok, output, took } => {
let verdict = if *ok { "ok" } else { "failed" };
out.push_str(&format!("setup {verdict} in {}s\n", took.as_secs()));
push_output(&mut out, output);
}
Script::TimedOut { after, output } => {
out.push_str(&format!("setup killed after {}s\n", after.as_secs()));
push_output(&mut out, output);
}
Script::Failed(why) => out.push_str(&format!("setup could not run: {why}\n")),
}
Some(out)
}
}
fn push_output(out: &mut String, output: &str) {
if output.trim().is_empty() {
return;
}
out.push('\n');
for line in output.lines() {
out.push_str(" ");
out.push_str(line);
out.push('\n');
}
out.push('\n');
}
pub fn prepare(ctx: &Context) -> Prepared {
let carried = carry(ctx);
let setup = match script(ctx.repo, SETUP) {
Some(path) => run(&path, ctx, SETUP_TIMEOUT),
None => Script::Absent,
};
Prepared {
repo_name: ctx.repo_name.to_string(),
carried,
setup,
}
}
pub fn teardown(ctx: &Context) -> Script {
match script(ctx.repo, TEARDOWN) {
Some(path) => run(&path, ctx, TEARDOWN_TIMEOUT),
None => Script::Absent,
}
}
pub fn write_log(workspace: &Path, prepared: &[Prepared]) -> std::io::Result<Option<PathBuf>> {
let sections: Vec<String> = prepared.iter().filter_map(|p| p.section()).collect();
if sections.is_empty() {
return Ok(None);
}
let path = workspace.join(LOG_FILE);
std::fs::write(&path, sections.join("\n"))?;
Ok(Some(path))
}
fn include_list(repo: &Path) -> (Vec<PathBuf>, Vec<String>) {
let Ok(text) = std::fs::read_to_string(repo.join(DIR).join(INCLUDE)) else {
return (Vec::new(), Vec::new());
};
let mut paths = Vec::new();
let mut refused = Vec::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let path = Path::new(line);
if path.is_absolute() || path.components().any(|c| c.as_os_str() == "..") {
refused.push(line.to_string());
continue;
}
paths.push(path.to_path_buf());
}
(paths, refused)
}
fn carry(ctx: &Context) -> Carried {
let (paths, refused) = include_list(ctx.repo);
let mut carried = Carried {
refused,
..Default::default()
};
for rel in paths {
let src = ctx.repo.join(&rel);
if !src.symlink_metadata().is_ok() {
carried.missing.push(rel);
continue;
}
let dst = ctx.worktree.join(&rel);
if dst.symlink_metadata().is_ok() {
continue;
}
if !lands_inside(ctx.worktree, &dst) {
carried.refused.push(rel.display().to_string());
continue;
}
match copy_across(&src, &dst) {
Ok(()) => carried.copied.push(rel),
Err(err) => carried.failed.push((rel, err.to_string())),
}
}
carried
}
fn lands_inside(worktree: &Path, dst: &Path) -> bool {
let Ok(root) = std::fs::canonicalize(worktree) else {
return false;
};
let mut probe = dst;
while let Some(parent) = probe.parent() {
match std::fs::canonicalize(parent) {
Ok(resolved) => return resolved.starts_with(&root),
Err(_) => probe = parent,
}
}
false
}
fn copy_across(src: &Path, dst: &Path) -> std::io::Result<()> {
if let Some(parent) = dst.parent() {
std::fs::create_dir_all(parent)?;
}
if std::fs::symlink_metadata(src)?.file_type().is_symlink() {
return std::os::unix::fs::symlink(std::fs::read_link(src)?, dst);
}
if clone(src, dst) {
return Ok(());
}
copy_recursive(src, dst)
}
#[cfg(target_os = "macos")]
fn clone(src: &Path, dst: &Path) -> bool {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
let (Ok(src), Ok(dst)) = (
CString::new(src.as_os_str().as_bytes()),
CString::new(dst.as_os_str().as_bytes()),
) else {
return false;
};
unsafe { libc::clonefile(src.as_ptr(), dst.as_ptr(), 0) == 0 }
}
#[cfg(not(target_os = "macos"))]
fn clone(_src: &Path, _dst: &Path) -> bool {
false
}
fn copy_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
let meta = std::fs::symlink_metadata(src)?;
if meta.file_type().is_symlink() {
return std::os::unix::fs::symlink(std::fs::read_link(src)?, dst);
}
if meta.is_dir() {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
copy_recursive(&entry.path(), &dst.join(entry.file_name()))?;
}
return Ok(());
}
std::fs::copy(src, dst).map(|_| ())
}
fn script(repo: &Path, name: &str) -> Option<PathBuf> {
let path = repo.join(DIR).join(name);
path.is_file().then_some(path)
}
fn command_for(path: &Path) -> Command {
use std::os::unix::fs::PermissionsExt;
let executable = std::fs::metadata(path)
.map(|meta| meta.permissions().mode() & 0o111 != 0)
.unwrap_or(false);
if executable {
Command::new(path)
} else {
let mut command = Command::new("sh");
command.arg(path);
command
}
}
fn run(path: &Path, ctx: &Context, timeout: Duration) -> Script {
use std::os::unix::process::CommandExt;
let started = Instant::now();
let mut command = command_for(path);
command
.current_dir(ctx.worktree)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.env("MARVER_WORKTREE", ctx.worktree)
.env("MARVER_REPO", ctx.repo)
.env("MARVER_REPO_NAME", ctx.repo_name)
.env("MARVER_BRANCH", ctx.branch)
.env("MARVER_BASE", ctx.base)
.env("MARVER_TASK", ctx.task_id.to_string())
.env("MARVER_WORKSPACE", ctx.workspace);
unsafe {
command.pre_exec(|| {
if libc::setpgid(0, 0) == -1 {
return Err(std::io::Error::last_os_error());
}
Ok(())
});
}
let mut child = match command.spawn() {
Ok(child) => child,
Err(err) => return Script::Failed(err.to_string()),
};
let pid = child.id() as libc::pid_t;
let out = drain(child.stdout.take());
let err = drain(child.stderr.take());
let (tx, rx) = channel();
std::thread::spawn(move || {
let _ = tx.send(child.wait());
});
let collected = |grace: Duration| {
std::thread::sleep(grace);
let (out, out_lost) = out.take();
let (err, err_lost) = err.take();
merge(&out, &err, out_lost + err_lost)
};
match rx.recv_timeout(timeout) {
Ok(Ok(status)) => Script::Ran {
ok: status.success(),
output: collected(SETTLE),
took: started.elapsed(),
},
Ok(Err(err)) => Script::Failed(err.to_string()),
Err(RecvTimeoutError::Disconnected) => {
Script::Failed("the script could not be waited for".to_string())
}
Err(RecvTimeoutError::Timeout) => {
unsafe { libc::kill(-pid, libc::SIGKILL) };
Script::TimedOut {
after: timeout,
output: collected(REAP_GRACE),
}
}
}
}
struct Drained(std::sync::Arc<std::sync::Mutex<Tail>>);
impl Drained {
fn take(&self) -> (Vec<u8>, usize) {
self.0
.lock()
.map(|held| (held.kept.clone(), held.dropped))
.unwrap_or_default()
}
}
#[derive(Default)]
struct Tail {
kept: Vec<u8>,
dropped: usize,
}
fn drain<R: std::io::Read + Send + 'static>(source: Option<R>) -> Drained {
let held = std::sync::Arc::new(std::sync::Mutex::new(Tail::default()));
let Some(mut source) = source else {
return Drained(held);
};
let into = std::sync::Arc::clone(&held);
std::thread::spawn(move || {
let mut buffer = [0u8; 8192];
loop {
match source.read(&mut buffer) {
Ok(0) | Err(_) => break,
Ok(read) => {
let Ok(mut into) = into.lock() else { break };
into.kept.extend_from_slice(&buffer[..read]);
if into.kept.len() > LIMIT {
let over = into.kept.len() - LIMIT;
into.kept.drain(..over);
into.dropped += over;
}
}
}
}
});
Drained(held)
}
fn merge(out: &[u8], err: &[u8], lost: usize) -> String {
let mut text = String::from_utf8_lossy(out).into_owned();
let stderr = String::from_utf8_lossy(err);
if !stderr.trim().is_empty() {
if !text.is_empty() && !text.ends_with('\n') {
text.push('\n');
}
text.push_str(&stderr);
}
let text = text.trim_end().to_string();
if text.len() <= MAX_OUTPUT && lost == 0 {
return text;
}
let mut cut = text.len().saturating_sub(MAX_OUTPUT);
while cut < text.len() && !text.is_char_boundary(cut) {
cut += 1;
}
format!("[…{} bytes earlier]\n{}", lost + cut, &text[cut..])
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
struct Fixture {
_tmp: TempDir,
repo: PathBuf,
worktree: PathBuf,
workspace: PathBuf,
}
impl Fixture {
fn new() -> Self {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("repo");
let workspace = tmp.path().join("workspace");
let worktree = workspace.join("repo");
std::fs::create_dir_all(&repo).unwrap();
std::fs::create_dir_all(&worktree).unwrap();
Self {
_tmp: tmp,
repo,
worktree,
workspace,
}
}
fn ctx(&self) -> Context<'_> {
Context {
repo: &self.repo,
repo_name: "repo",
worktree: &self.worktree,
branch: "marver/1-thing",
base: "main",
task_id: 1,
workspace: &self.workspace,
}
}
fn marver_file(&self, name: &str, body: &str) -> PathBuf {
let dir = self.repo.join(DIR);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(name);
std::fs::write(&path, body).unwrap();
path
}
fn executable(&self, name: &str, body: &str) -> PathBuf {
use std::os::unix::fs::PermissionsExt;
let path = self.marver_file(name, body);
let mut perms = std::fs::metadata(&path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&path, perms).unwrap();
path
}
}
#[test]
fn a_repo_with_nothing_configured_is_silent() {
let fx = Fixture::new();
let prepared = prepare(&fx.ctx());
assert!(prepared.is_quiet());
assert_eq!(prepared.setup, Script::Absent);
assert!(prepared.summary().is_none());
assert_eq!(
write_log(&fx.workspace, &[prepared]).unwrap(),
None,
"an empty log would still have to be explained"
);
}
#[test]
fn listed_files_are_carried_across() {
let fx = Fixture::new();
std::fs::write(fx.repo.join(".env"), "SECRET=1\n").unwrap();
std::fs::create_dir_all(fx.repo.join("node_modules/pkg")).unwrap();
std::fs::write(fx.repo.join("node_modules/pkg/index.js"), "//\n").unwrap();
fx.marver_file(INCLUDE, "# what the tests need\n.env\nnode_modules\n\n");
let carried = carry(&fx.ctx());
assert_eq!(carried.failed, vec![]);
assert_eq!(carried.copied.len(), 2);
assert_eq!(
std::fs::read_to_string(fx.worktree.join(".env")).unwrap(),
"SECRET=1\n"
);
assert!(
fx.worktree.join("node_modules/pkg/index.js").exists(),
"a directory is carried whole"
);
}
#[test]
fn a_listed_path_that_is_not_there_is_not_a_failure() {
let fx = Fixture::new();
fx.marver_file(INCLUDE, ".env.local\n");
let carried = carry(&fx.ctx());
assert!(carried.is_quiet());
assert_eq!(carried.missing, vec![PathBuf::from(".env.local")]);
assert!(carried.copied.is_empty());
}
#[test]
fn a_path_reaching_outside_the_repo_is_refused() {
let fx = Fixture::new();
fx.marver_file(INCLUDE, "../../../etc/passwd\n/etc/hosts\n.env\n");
std::fs::write(fx.repo.join(".env"), "ok\n").unwrap();
let carried = carry(&fx.ctx());
assert_eq!(carried.refused.len(), 2);
assert!(!carried.is_quiet(), "it must be said out loud");
assert_eq!(
carried.copied,
vec![PathBuf::from(".env")],
"the honest lines still work"
);
assert!(!fx.worktree.join("passwd").exists());
}
#[test]
fn a_line_that_reaches_outside_through_a_symlink_is_refused_too() {
let fx = Fixture::new();
let outside = fx._tmp.path().join("outside");
std::fs::create_dir_all(&outside).unwrap();
std::os::unix::fs::symlink(&outside, fx.worktree.join("conf")).unwrap();
std::fs::create_dir_all(fx.repo.join("conf")).unwrap();
std::fs::write(fx.repo.join("conf/marver"), "carried\n").unwrap();
fx.marver_file(INCLUDE, "conf/marver\n");
let carried = carry(&fx.ctx());
assert_eq!(carried.refused, ["conf/marver"], "{carried:?}");
assert!(carried.copied.is_empty());
assert!(
!outside.join("marver").exists(),
"nothing may be written outside the worktree"
);
}
#[test]
fn an_ordinary_nested_path_is_still_carried() {
let fx = Fixture::new();
std::fs::create_dir_all(fx.repo.join("deep/nested")).unwrap();
std::fs::write(fx.repo.join("deep/nested/.env"), "SECRET=1\n").unwrap();
fx.marver_file(INCLUDE, "deep/nested/.env\n");
let carried = carry(&fx.ctx());
assert!(carried.is_quiet(), "{carried:?}");
assert_eq!(carried.copied, [PathBuf::from("deep/nested/.env")]);
assert_eq!(
std::fs::read_to_string(fx.worktree.join("deep/nested/.env")).unwrap(),
"SECRET=1\n"
);
}
#[test]
fn what_git_already_put_there_is_not_overwritten() {
let fx = Fixture::new();
std::fs::write(fx.repo.join("config.toml"), "from the repo\n").unwrap();
std::fs::write(
fx.worktree.join("config.toml"),
"tracked, and checked out\n",
)
.unwrap();
fx.marver_file(INCLUDE, "config.toml\n");
let carried = carry(&fx.ctx());
assert!(carried.copied.is_empty());
assert_eq!(
std::fs::read_to_string(fx.worktree.join("config.toml")).unwrap(),
"tracked, and checked out\n",
"a clean worktree must stay clean"
);
}
#[test]
fn a_symlink_is_recreated_rather_than_followed() {
let fx = Fixture::new();
std::fs::write(fx.repo.join("real.env"), "x\n").unwrap();
std::os::unix::fs::symlink("real.env", fx.repo.join(".env")).unwrap();
fx.marver_file(INCLUDE, ".env\n");
carry(&fx.ctx());
let meta = std::fs::symlink_metadata(fx.worktree.join(".env")).unwrap();
assert!(meta.file_type().is_symlink());
}
#[test]
fn setup_runs_in_the_worktree_and_is_told_where_it_is() {
let fx = Fixture::new();
fx.executable(
SETUP,
"#!/bin/sh\npwd > where\necho \"$MARVER_BRANCH\" > branch\necho \"$MARVER_TASK\" > task\n",
);
let prepared = prepare(&fx.ctx());
assert!(prepared.is_quiet(), "{:?}", prepared.setup);
assert_eq!(
std::fs::read_to_string(fx.worktree.join("branch"))
.unwrap()
.trim(),
"marver/1-thing"
);
assert_eq!(
std::fs::read_to_string(fx.worktree.join("task"))
.unwrap()
.trim(),
"1"
);
let where_it_ran = std::fs::read_to_string(fx.worktree.join("where")).unwrap();
assert!(
where_it_ran.trim().ends_with("repo"),
"it ran in {where_it_ran}"
);
}
#[test]
fn a_script_without_the_executable_bit_still_runs() {
let fx = Fixture::new();
fx.marver_file(SETUP, "echo hello\n");
let prepared = prepare(&fx.ctx());
match prepared.setup {
Script::Ran { ok, output, .. } => {
assert!(ok);
assert_eq!(output, "hello");
}
other => panic!("expected it to run: {other:?}"),
}
}
#[test]
fn a_failing_setup_is_reported_and_does_not_panic() {
let fx = Fixture::new();
fx.executable(SETUP, "#!/bin/sh\necho 'no lockfile' >&2\nexit 1\n");
let prepared = prepare(&fx.ctx());
assert!(!prepared.is_quiet());
assert_eq!(
prepared.summary().as_deref(),
Some("repo: .marver/setup failed")
);
match &prepared.setup {
Script::Ran { ok, output, .. } => {
assert!(!ok);
assert!(output.contains("no lockfile"), "stderr must survive");
}
other => panic!("expected a run: {other:?}"),
}
}
#[test]
fn output_from_both_streams_reaches_the_log() {
let fx = Fixture::new();
fx.executable(SETUP, "#!/bin/sh\necho out\necho err >&2\n");
let prepared = prepare(&fx.ctx());
let path = write_log(&fx.workspace, std::slice::from_ref(&prepared))
.unwrap()
.expect("something was said");
let log = std::fs::read_to_string(path).unwrap();
assert!(log.contains("# repo"));
assert!(log.contains("out"), "{log}");
assert!(log.contains("err"), "{log}");
}
#[test]
fn a_script_that_never_ends_is_killed_with_its_children() {
let fx = Fixture::new();
fx.executable(SETUP, "#!/bin/sh\nsleep 120 &\nsleep 120\n");
let ctx = fx.ctx();
let outcome = run(
&fx.repo.join(DIR).join(SETUP),
&ctx,
Duration::from_millis(300),
);
match outcome {
Script::TimedOut { after, .. } => assert_eq!(after, Duration::from_millis(300)),
other => panic!("expected a timeout: {other:?}"),
}
}
#[test]
fn a_script_that_leaves_something_running_is_still_over_when_it_exits() {
let fx = Fixture::new();
fx.executable(SETUP, "#!/bin/sh\necho ready\nsleep 30 &\n");
let ctx = fx.ctx();
let started = Instant::now();
let outcome = run(
&fx.repo.join(DIR).join(SETUP),
&ctx,
Duration::from_secs(10),
);
assert!(
started.elapsed() < Duration::from_secs(5),
"waited {:?} for a script that exited at once",
started.elapsed()
);
match outcome {
Script::Ran { ok, output, .. } => {
assert!(ok);
assert_eq!(output, "ready", "and what it said arrives with it");
}
other => panic!("expected a run: {other:?}"),
}
}
#[test]
fn teardown_runs_when_there_is_one() {
let fx = Fixture::new();
fx.executable(
TEARDOWN,
"#!/bin/sh\necho gone > $MARVER_WORKSPACE/left-behind\n",
);
std::fs::create_dir_all(&fx.workspace).unwrap();
let outcome = teardown(&fx.ctx());
assert!(outcome.is_quiet(), "{outcome:?}");
assert!(fx.workspace.join("left-behind").exists());
}
#[test]
fn teardown_is_absent_when_only_setup_exists() {
let fx = Fixture::new();
fx.executable(SETUP, "#!/bin/sh\ntrue\n");
assert_eq!(teardown(&fx.ctx()), Script::Absent);
}
#[test]
fn a_torrent_of_output_is_bounded_while_it_arrives() {
let fx = Fixture::new();
fx.executable(
SETUP,
"#!/bin/sh\nawk 'BEGIN{for(i=0;i<400000;i++) print \"padding line\" i}'\necho THE_REASON\n",
);
let ctx = fx.ctx();
let outcome = run(
&fx.repo.join(DIR).join(SETUP),
&ctx,
Duration::from_secs(30),
);
match outcome {
Script::Ran { ok, output, .. } => {
assert!(ok);
assert!(
output.ends_with("THE_REASON"),
"the tail is still the point"
);
assert!(
output.len() <= MAX_OUTPUT + 64,
"kept {} bytes of a multi-megabyte script",
output.len()
);
assert!(
output.starts_with("[…"),
"and the cut is admitted: {}",
&output[..40]
);
}
other => panic!("expected a run: {other:?}"),
}
}
#[test]
fn a_long_output_keeps_its_end() {
let fx = Fixture::new();
fx.executable(
SETUP,
"#!/bin/sh\nawk 'BEGIN{for(i=0;i<20000;i++) print \"line\" i}'\necho THE_REASON\n",
);
let prepared = prepare(&fx.ctx());
match &prepared.setup {
Script::Ran { output, .. } => {
assert!(output.len() <= MAX_OUTPUT + 64, "{}", output.len());
assert!(output.ends_with("THE_REASON"), "the tail is the point");
assert!(output.starts_with("[…"), "and the cut is admitted");
}
other => panic!("expected a run: {other:?}"),
}
}
#[test]
fn the_log_gathers_every_repo() {
let fx = Fixture::new();
let quiet = Prepared {
repo_name: "web".to_string(),
carried: Carried::default(),
setup: Script::Absent,
};
let noisy = Prepared {
repo_name: "api".to_string(),
carried: Carried::default(),
setup: Script::Ran {
ok: false,
output: "boom".to_string(),
took: Duration::from_secs(2),
},
};
let path = write_log(&fx.workspace, &[quiet, noisy]).unwrap().unwrap();
let log = std::fs::read_to_string(path).unwrap();
assert!(log.contains("# api"));
assert!(
!log.contains("# web"),
"a repo with nothing to say says nothing"
);
}
}