mod common;
use std::path::PathBuf;
use common::*;
use semora_proto::Scope;
use semora_server::auth::{apikey, users};
#[tokio::test(flavor = "multi_thread")]
async fn project_init_creates_the_project_and_writes_dot_semora() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("My Docs_v2/readme.md", "# x");
let out = cli.run(&["project", "init", "My Docs_v2"]).await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert!(out.stdout.contains("Created project"), "{}", out.stdout);
assert!(out.stdout.contains("Commit it"), "{}", out.stdout);
assert!(
out.stdout.contains(&format!("{}/p/my-docs-v2", server.url)),
"{}",
out.stdout
);
let text = std::fs::read_to_string(cli.path("My Docs_v2/.semora")).unwrap();
assert!(text.contains("project = \"my-docs-v2\""), "{text}");
assert!(
text.contains(&format!("server = \"{}\"", server.url)),
"{text}"
);
let list = cli.run(&["project", "ls", "--json"]).await.json();
assert_eq!(list[0]["slug"], "my-docs-v2");
assert_eq!(
list[0]["name"], "My Docs_v2",
"the display name is the directory name"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn project_init_links_an_existing_project_and_name_picks_the_id() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("one/x.md", "# x");
cli.write("two/x.md", "# x");
assert_eq!(
cli.run(&["project", "init", "one", "--name", "shared"])
.await
.code,
0
);
let out = cli
.run(&["project", "init", "two", "--name", "shared"])
.await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert!(
out.stdout.contains("Linked to your existing project"),
"{}",
out.stdout
);
assert!(
std::fs::read_to_string(cli.path("two/.semora"))
.unwrap()
.contains("project = \"shared\"")
);
let bad = cli
.run(&["project", "init", "one", "--name", "Not Valid"])
.await;
assert_ne!(bad.code, 0);
assert!(bad.stderr.contains("lowercase"), "{}", bad.stderr);
}
#[tokio::test(flavor = "multi_thread")]
async fn project_init_for_someone_elses_id_suggests_name() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("taken/x.md", "# x");
assert_eq!(cli.run(&["project", "init", "taken"]).await.code, 0);
let bob = users::create(
&server.state.db,
"bob@example.com",
"Bob",
"correct horse battery",
false,
)
.await
.unwrap();
let (_, bob_key) = apikey::create(&server.state, bob.id, "bob", &Scope::all(), None, "web")
.await
.unwrap();
let mut bob_cli = Cli::new(&server);
bob_cli.key = Some(bob_key);
bob_cli.write("taken/x.md", "# x");
let out = bob_cli.run(&["project", "init", "taken"]).await;
assert_ne!(out.code, 0);
assert!(
out.stderr.contains("belongs to someone else") && out.stderr.contains("--name"),
"{}",
out.stderr
);
assert!(
!bob_cli.path("taken/.semora").exists(),
"nothing is written"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn project_init_twice_is_quiet_and_a_different_id_is_refused() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("proj/x.md", "# x");
assert_eq!(cli.run(&["project", "init", "proj"]).await.code, 0);
let again = cli.run(&["project", "init", "proj"]).await;
assert_eq!(again.code, 0, "{}", again.stderr);
assert!(
!again.stdout.contains("Commit it"),
"the file was already right: {}",
again.stdout
);
let other = cli
.run(&["project", "init", "proj", "--name", "elsewhere"])
.await;
assert_ne!(other.code, 0);
assert!(
other.stderr.contains("already links this folder"),
"{}",
other.stderr
);
assert!(
cli.run(&["project", "ls", "--json"])
.await
.json()
.as_array()
.unwrap()
.len()
== 1,
"nothing was sent for the refused one"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn project_init_refuses_a_dot_semora_for_another_server() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("proj/x.md", "# x");
cli.write(
"proj/.semora",
"server = \"https://other.example.com\"\nproject = \"proj\"\n",
);
let out = cli.run(&["project", "init", "proj"]).await;
assert_ne!(out.code, 0);
assert!(
out.stderr.contains("already links this folder")
&& out.stderr.contains("https://other.example.com"),
"{}",
out.stderr
);
assert!(
cli.run(&["project", "ls", "--json"])
.await
.json()
.as_array()
.unwrap()
.is_empty(),
"nothing was sent"
);
assert!(
std::fs::read_to_string(cli.path("proj/.semora"))
.unwrap()
.contains("other.example.com"),
"the file is untouched"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn project_init_recreates_a_project_that_is_gone_on_the_server() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("proj/x.md", "# x");
cli.write(
"proj/.semora",
format!("server = \"{}\"\nproject = \"ghost\"\n", server.url),
);
let out = cli.run(&["project", "init", "proj"]).await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert!(
out.stdout.contains("Created project") && out.stdout.contains("(ghost)"),
"{}",
out.stdout
);
assert!(
!out.stdout.contains("Commit it"),
"the file was already there: {}",
out.stdout
);
}
#[tokio::test(flavor = "multi_thread")]
async fn project_init_in_a_checkout_named_otherwise_uses_the_id_in_dot_semora() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("semora/x.md", "# x");
assert_eq!(cli.run(&["project", "init", "semora"]).await.code, 0);
cli.write("worktrees/projects/x.md", "# x");
cli.write(
"worktrees/projects/.semora",
format!("server = \"{}\"\nproject = \"semora\"\n", server.url),
);
let out = cli.run(&["project", "init", "worktrees/projects"]).await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert!(
out.stdout.contains("Linked to your existing project"),
"{}",
out.stdout
);
let projects = cli.run(&["project", "ls", "--json"]).await.json();
assert_eq!(
projects.as_array().unwrap().len(),
1,
"no second project: {projects}"
);
let other = cli
.run(&[
"project",
"init",
"worktrees/projects",
"--name",
"projects",
])
.await;
assert_ne!(other.code, 0);
assert!(
other.stderr.contains("already links this folder"),
"{}",
other.stderr
);
}
#[tokio::test(flavor = "multi_thread")]
async fn project_ls_prints_one_line_per_project() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("alpha/x.md", "# x");
cli.write("beta/x.md", "# x");
cli.run(&["project", "init", "alpha"]).await;
cli.run(&["project", "init", "beta"]).await;
let out = cli.run(&["project", "ls"]).await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert!(
out.stdout.contains("alpha") && out.stdout.contains("beta"),
"{}",
out.stdout
);
assert!(out.stdout.contains("0 docs"), "{}", out.stdout);
}
async fn run_in(cli: &Cli, dir: PathBuf, args: &[&str]) -> Out {
let mut cmd = cli.command(args);
cmd.current_dir(dir);
tokio::task::spawn_blocking(move || {
let o = cmd.output().expect("run semora");
Out {
code: o.status.code().unwrap_or(-1),
stdout: String::from_utf8_lossy(&o.stdout).into_owned(),
stderr: String::from_utf8_lossy(&o.stderr).into_owned(),
}
})
.await
.unwrap()
}
fn dot_semora(cli: &Cli, server: &Server, project: &str) {
cli.write(
".semora",
format!("server = \"{}\"\nproject = \"{project}\"\n", server.url),
);
}
fn with_machine(cli: &mut Cli, name: &str) {
cli.extra_env = vec![
("COMPUTERNAME".into(), name.into()),
("HOSTNAME".into(), name.into()),
];
}
#[tokio::test(flavor = "multi_thread")]
async fn a_project_push_prints_the_path_link_first() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("docs/plan.md", "# Plan");
assert_eq!(
cli.run(&["project", "init", ".", "--name", "semora"])
.await
.code,
0
);
let out = cli.run(&["push", "docs/plan.md"]).await;
assert_eq!(out.code, 0, "{}", out.stderr);
let path_at = out
.stdout
.find(&format!("{}/p/semora/docs/plan.md", server.url))
.unwrap_or_else(|| panic!("{}", out.stdout));
let url_at = out.stdout.find(&format!("{}/d/", server.url)).unwrap();
assert!(
path_at < url_at,
"the path link comes first: {}",
out.stdout
);
let again = cli.run(&["push", "docs/plan.md", "--json"]).await.json();
assert_eq!(again["rev"], 2, "the same key is the same document");
}
#[tokio::test(flavor = "multi_thread")]
async fn dot_semora_is_found_from_the_pushed_path_not_the_current_directory() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("proj/docs/x.md", "# X");
cli.write("elsewhere/y.md", "# Y");
assert_eq!(
cli.run(&["project", "init", "proj", "--name", "semora"])
.await
.code,
0
);
let out = run_in(
&cli,
cli.path("elsewhere"),
&["push", "../proj/docs/x.md", "--json"],
)
.await;
assert_eq!(out.code, 0, "{}", out.stderr);
let v = out.json();
assert_eq!(
(v["project"].as_str(), v["path"].as_str()),
(Some("semora"), Some("docs/x.md"))
);
let out = run_in(
&cli,
cli.path("proj"),
&["push", "../elsewhere/y.md", "--json"],
)
.await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert!(
out.json().get("project").is_none(),
"outside the project nothing changes"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn two_checkouts_update_one_document() {
let server = Server::start().await;
let mut a = Cli::new(&server);
with_machine(&mut a, "BoxA");
a.write("docs/plan.md", "# Plan A");
a.write(".git/HEAD", "ref: refs/heads/feat/a\n");
assert_eq!(
a.run(&["project", "init", ".", "--name", "semora"])
.await
.code,
0
);
let first = a.run(&["push", "docs/plan.md", "--json"]).await.json();
let mut b = Cli::new(&server);
with_machine(&mut b, "BoxB");
b.write("docs/plan.md", "# Plan B");
b.write(".git/HEAD", "ref: refs/heads/feat/b\n");
dot_semora(&b, &server, "semora");
let second = b.run(&["push", "docs/plan.md", "--json"]).await.json();
assert_eq!(second["id"], first["id"], "one path, one document");
assert_eq!(second["rev"], 2);
let shown = a
.run(&["show", first["id"].as_str().unwrap(), "--json"])
.await
.json();
let origins: Vec<&str> = shown["revisions"]
.as_array()
.unwrap()
.iter()
.map(|r| r["origin"].as_str().unwrap())
.collect();
assert_eq!(
origins,
["boxb \u{b7} feat/b", "boxa \u{b7} feat/a"],
"newest first"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn dot_semora_for_another_server_stops_the_push() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("docs/plan.md", "# P");
cli.write(
".semora",
"server = \"https://other.example.com\"\nproject = \"semora\"\n",
);
let out = cli.run(&["push", "docs/plan.md"]).await;
assert_eq!(out.code, 1, "{}", out.stderr);
assert!(
out.stderr.contains("https://other.example.com") && out.stderr.contains(&server.url),
"{}",
out.stderr
);
assert!(
out.stderr.contains("--server") && out.stderr.contains("--id or --new"),
"push adds its own way out: {}",
out.stderr
);
let ls = cli.run(&["ls", "--json"]).await.json();
assert_eq!(ls["docs"].as_array().unwrap().len(), 0, "nothing was sent");
}
#[tokio::test(flavor = "multi_thread")]
async fn an_old_remembered_document_is_carried_into_the_project() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("docs/plan.md", "# Old");
let old = cli.run(&["push", "docs/plan.md", "--json"]).await.json();
assert_eq!(cli.cache().len(), 1);
assert_eq!(
cli.run(&["project", "init", ".", "--name", "semora"])
.await
.code,
0
);
cli.write("docs/plan.md", "# New");
let v = cli.run(&["push", "docs/plan.md", "--json"]).await.json();
assert_eq!(v["id"], old["id"], "links already posted keep working");
assert_eq!(v["rev"], 2);
assert_eq!(v["path"], "docs/plan.md");
assert_eq!(
cli.cache().len(),
1,
"document.cache is not written for keyed pushes"
);
assert_eq!(cli.cache()[0][2], old["id"].as_str().unwrap());
let shown = cli
.run(&["show", old["id"].as_str().unwrap(), "--json"])
.await
.json();
assert_eq!(shown["project"], "semora");
}
#[tokio::test(flavor = "multi_thread")]
async fn id_and_new_inside_a_project_push_outside_it_and_say_so() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("docs/plan.md", "# P");
assert_eq!(
cli.run(&["project", "init", ".", "--name", "semora"])
.await
.code,
0
);
let keyed = cli.run(&["push", "docs/plan.md", "--json"]).await.json();
let id = keyed["id"].as_str().unwrap();
let out = cli.run(&["push", "docs/plan.md", "--new", "--json"]).await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert!(
out.stderr
.contains("note: --new makes a document outside project semora"),
"{}",
out.stderr
);
let fresh = out.json();
assert_ne!(fresh["id"], keyed["id"]);
assert!(fresh.get("project").is_none(), "{fresh}");
let out = cli
.run(&["push", "docs/plan.md", "--id", id, "--json"])
.await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert!(
out.stderr
.contains("note: --id pushes to that document directly, not into project semora"),
"{}",
out.stderr
);
assert_eq!(out.json()["id"], id);
}
#[tokio::test(flavor = "multi_thread")]
async fn pushing_the_project_root_is_refused() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("readme.md", "# Root");
cli.write("notes.xyz", "a file type the server does not serve");
assert_eq!(
cli.run(&["project", "init", ".", "--name", "semora"])
.await
.code,
0
);
let out = cli.run(&["push", "."]).await;
assert_eq!(out.code, 1);
assert!(
out.stderr.contains("not the project root"),
"{}",
out.stderr
);
assert!(
!out.stderr.contains("skipped"),
"the folder was not packed first: {}",
out.stderr
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_missing_project_gives_a_clear_push_error() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("docs/plan.md", "# P");
dot_semora(&cli, &server, "ghost");
let out = cli.run(&["push", "docs/plan.md"]).await;
assert_eq!(out.code, 4, "{}", out.stderr);
assert!(
out.stderr.contains("ghost") && out.stderr.contains("semora project init"),
"{}",
out.stderr
);
assert!(
out.stderr.contains("no longer have access"),
"{}",
out.stderr
);
}
async fn old_server() -> String {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, axum::Router::new()).await.unwrap();
});
format!("http://{addr}")
}
#[tokio::test(flavor = "multi_thread")]
async fn project_commands_on_an_older_server_say_so() {
let server = Server::start().await;
let mut cli = Cli::new(&server);
cli.url = old_server().await;
cli.write("proj/x.md", "# x");
for args in [&["project", "init", "proj"][..], &["project", "ls"][..]] {
let out = cli.run(args).await;
assert_ne!(out.code, 0, "{args:?}");
assert!(
out.stderr.contains("older than this CLI"),
"{args:?}: {}",
out.stderr
);
}
assert!(!cli.path("proj/.semora").exists(), "nothing is written");
}
#[tokio::test(flavor = "multi_thread")]
async fn ls_project_lists_only_that_project() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("docs/a.md", "# A");
cli.write("b.md", "# B");
assert_eq!(
cli.run(&["project", "init", ".", "--name", "semora"])
.await
.code,
0
);
let a = cli.run(&["push", "docs/a.md", "--json"]).await.json();
cli.run(&["push", "b.md", "--new", "--json"]).await;
let v = cli
.run(&["ls", "--project", "semora", "--json"])
.await
.json();
let ids: Vec<&str> = v["docs"]
.as_array()
.unwrap()
.iter()
.map(|d| d["id"].as_str().unwrap())
.collect();
assert_eq!(ids, [a["id"].as_str().unwrap()]);
let text = cli.run(&["ls", "--project", "semora"]).await;
assert!(text.stdout.contains("semora:docs/a.md"), "{}", text.stdout);
let all = cli.run(&["ls"]).await;
assert!(
all.stdout.contains("semora:docs/a.md"),
"project documents show their key everywhere: {}",
all.stdout
);
let nope = cli.run(&["ls", "--project", "nope"]).await;
assert_eq!(nope.code, 4, "{}", nope.stderr);
assert!(nope.stderr.contains("no project `nope`"), "{}", nope.stderr);
}
#[tokio::test(flavor = "multi_thread")]
async fn show_prints_project_path_link_and_origin() {
let server = Server::start().await;
let mut cli = Cli::new(&server);
with_machine(&mut cli, "BoxA");
cli.write("docs/a.md", "# A");
cli.write(".git/HEAD", "ref: refs/heads/feat/a\n");
assert_eq!(
cli.run(&["project", "init", ".", "--name", "semora"])
.await
.code,
0
);
let a = cli.run(&["push", "docs/a.md", "--json"]).await.json();
let out = cli.run(&["show", a["id"].as_str().unwrap()]).await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert!(out.stdout.contains(" proj semora"), "{}", out.stdout);
assert!(out.stdout.contains(" path docs/a.md"), "{}", out.stdout);
assert!(
out.stdout
.contains(&format!(" link {}/p/semora/docs/a.md", server.url)),
"{}",
out.stdout
);
assert!(
out.stdout.contains("from boxa \u{b7} feat/a"),
"{}",
out.stdout
);
}
#[tokio::test(flavor = "multi_thread")]
async fn path_links_work_where_a_document_is_expected() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("docs/plan.md", "# Plan\nbody");
cli.write("site/index.md", "# Site");
cli.write("site/page.md", "# Page");
assert_eq!(
cli.run(&["project", "init", ".", "--name", "semora"])
.await
.code,
0
);
let plan = cli.run(&["push", "docs/plan.md", "--json"]).await.json();
let site = cli.run(&["push", "site", "--json"]).await.json();
let link = plan["path_url"].as_str().unwrap().to_string();
let shown = cli.run(&["show", &link, "--json"]).await.json();
assert_eq!(shown["id"], plan["id"]);
let got = cli.run(&["get", &link]).await;
assert_eq!(got.stdout, "# Plan\nbody");
assert_eq!(cli.run(&["tag", &link, "+x"]).await.code, 0);
let page_link = format!("{}/p/semora/site/page.md", server.url);
assert_eq!(
cli.run(&["show", &page_link, "--json"]).await.json()["id"],
site["id"],
"a page inside a bundle names the bundle"
);
cli.write("other.md", "# Other");
let out = cli
.run(&["push", "other.md", "--id", &link, "--json"])
.await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert_eq!(out.json()["id"], plan["id"], "--id takes a path link too");
let wrong = cli
.run(&["show", "https://other.example.com/p/semora/docs/plan.md"])
.await;
assert_eq!(wrong.code, 1);
assert!(
wrong.stderr.contains("other.example.com") && wrong.stderr.contains(&server.url),
"{}",
wrong.stderr
);
let page = cli
.run(&["show", &format!("{}/p/semora", server.url)])
.await;
assert_eq!(page.code, 1);
assert!(page.stderr.contains("project page"), "{}", page.stderr);
let missing = cli
.run(&["show", &format!("{}/p/semora/nope.md", server.url)])
.await;
assert_eq!(missing.code, 4, "{}", missing.stderr);
assert!(
missing.stderr.contains("nothing at `nope.md` in semora"),
"{}",
missing.stderr
);
let folder = cli
.run(&["show", &format!("{}/p/semora/docs", server.url)])
.await;
assert_eq!(folder.code, 4, "{}", folder.stderr);
assert!(
folder.stderr.contains("`docs` is a folder in semora"),
"{}",
folder.stderr
);
}
#[tokio::test(flavor = "multi_thread")]
async fn watch_with_a_dot_semora_for_another_server_names_only_its_own_flag() {
let server = Server::start().await;
let cli = Cli::new(&server);
cli.write("docs/plan.md", "# P");
cli.write(
".semora",
"server = \"https://other.example.com\"\nproject = \"semora\"\n",
);
let out = cli.run(&["watch", "docs/plan.md"]).await;
assert_eq!(out.code, 1, "{}", out.stderr);
assert!(
out.stderr.contains("https://other.example.com") && out.stderr.contains("--id"),
"{}",
out.stderr
);
assert!(
!out.stderr.contains("--new"),
"watch has no --new: {}",
out.stderr
);
}
async fn old_server_with_documents() -> String {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let app = axum::Router::new().route(
"/api/v1/docs",
axum::routing::get(|| async {
axum::Json(serde_json::json!({"docs": [{
"id": "k3F9xQ2mLp", "title": "Not in a project", "kind": "md", "tags": [],
"rev": 1, "revision_count": 1, "url": "http://x/d/k3F9xQ2mLp/",
"created_at": "2026-10-01T12:00:00Z", "updated_at": "2026-10-01T12:00:00Z"
}]}))
}),
);
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
format!("http://{addr}")
}
#[tokio::test(flavor = "multi_thread")]
async fn ls_project_on_an_older_server_is_refused() {
let server = Server::start().await;
let mut cli = Cli::new(&server);
cli.url = old_server_with_documents().await;
for args in [
&["ls", "--project", "semora"][..],
&["ls", "--project", "semora", "--json"][..],
] {
let out = cli.run(args).await;
assert_ne!(out.code, 0, "{args:?}: {}", out.stdout);
assert!(
out.stderr.contains("older than this CLI"),
"{args:?}: {}",
out.stderr
);
assert!(
!out.stdout.contains("Not in a project"),
"{args:?}: nothing is listed: {}",
out.stdout
);
}
assert_eq!(cli.run(&["ls"]).await.code, 0);
}
#[tokio::test(flavor = "multi_thread")]
async fn project_init_in_a_collaborators_project_needs_join() {
let server = Server::start().await;
let mallory = users::create(
&server.state.db,
"mallory@example.com",
"Mallory",
"correct horse battery",
false,
)
.await
.unwrap();
let project = semora_server::projects::insert_project(
&server.state.db,
mallory.id,
"acme-api",
"acme-api",
)
.await
.unwrap()
.unwrap();
semora_server::collab::add_collaborator(
&server.state.db,
project.id,
server.user_id,
semora_proto::Level::Editor,
Some(mallory.id),
)
.await
.unwrap();
let cli = Cli::new(&server);
cli.write("acme-api/x.md", "# x");
let out = cli.run(&["project", "init", "acme-api"]).await;
assert_ne!(out.code, 0, "{}", out.stdout);
assert!(
out.stderr
.contains("Project `acme-api` belongs to Mallory, and you are an Editor in it.")
&& out.stderr.contains("semora project init --join"),
"{}",
out.stderr
);
assert!(
!cli.path("acme-api/.semora").exists(),
"nothing is written without --join"
);
let out = cli.run(&["project", "init", "acme-api", "--join"]).await;
assert_eq!(out.code, 0, "{}", out.stderr);
assert!(out.stdout.contains("Joined"), "{}", out.stdout);
assert!(cli.path("acme-api/.semora").exists());
}