use std::path::{Path, PathBuf};
use std::process::Command;
use std::thread::sleep;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
fn main() {
match run() {
Ok(()) => println!("e2e: PASSED"),
Err(error) => {
eprintln!("e2e: FAILED: {error}");
std::process::exit(1);
}
}
}
#[derive(Clone, Copy)]
enum Provider {
Github,
Gitlab,
Gitea,
}
impl Provider {
fn parse(value: &str) -> Result<Self, String> {
match value {
"github" => Ok(Self::Github),
"gitlab" => Ok(Self::Gitlab),
"gitea" => Ok(Self::Gitea),
other => Err(format!("unknown STK_E2E_PROVIDER {other:?}")),
}
}
fn cli(self) -> &'static str {
match self {
Self::Github => "gh",
Self::Gitlab => "glab",
Self::Gitea => "tea",
}
}
}
fn gitea_base() -> String {
env("STK_E2E_GITEA_URL").trim_end_matches('/').to_owned()
}
fn gitea_login() -> String {
env("STK_E2E_GITEA_LOGIN")
}
fn run() -> Result<(), String> {
let provider = Provider::parse(&env("STK_E2E_PROVIDER"))?;
let owner = env("STK_E2E_OWNER");
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|e| e.to_string())?
.as_nanos();
let slug = format!("{owner}/git-stk-e2e-{}-{stamp}", std::process::id());
create_repo(provider, &slug)?;
let _repo = RepoGuard {
provider,
slug: slug.clone(),
};
let dir = clone(provider, &slug)?;
let work = dir.as_path();
git(work, &["config", "user.email", "e2e@git-stk.test"])?;
git(work, &["config", "user.name", "git-stk e2e"])?;
git(work, &["config", "stk.mergeStrategy", "squash"])?;
git(work, &["config", "core.editor", "true"])?;
if let Provider::Gitea = provider {
let base = gitea_base();
let host = base
.split_once("://")
.map_or(base.as_str(), |(_, host)| host);
git(work, &["config", "stk.giteaHost", host])?;
}
core_lifecycle(provider, &slug, work)?;
issue_autoclose(provider, &slug, work)?;
metadata_surgery(work)?;
conflict_recovery(work)?;
undo_check(work)?;
split_check(work)?;
if let Provider::Github = provider {
github_native_stack(provider, &slug, work)?;
}
Ok(())
}
fn github_native_stack(provider: Provider, slug: &str, work: &Path) -> Result<(), String> {
match stacks_enabled(slug) {
Ok(true) => {}
Ok(false) => {
eprintln!("skipping native stacks: not enabled for {slug}");
return Ok(());
}
Err(error) => return Err(format!("could not tell if stacks are enabled: {error}")),
}
git(work, &["switch", "main"])?;
git(work, &["pull", "--ff-only"])?;
git(work, &["config", "stk.githubStacks", "true"])?;
let baseline = open_review_count(provider, slug)?;
stk(work, &["new", "ns/one"])?;
commit(work, "ns-one.txt", "one\n", "ns one")?;
stk(work, &["new", "ns/two"])?;
commit(work, "ns-two.txt", "two\n", "ns two")?;
let submitted = stk(work, &["submit", "--stack", "--push"])?;
if !submitted.contains("as a stack") {
return Err(format!("submit did not register a stack:\n{submitted}"));
}
wait_for_review_count(provider, slug, baseline + 2)?;
expect_open_stack(slug, &["ns/one", "ns/two"])?;
stk(work, &["unstack", "-y"])?;
if let Some(stack) = open_stack(slug)? {
return Err(format!("a stack is still open after unstack: {stack:?}"));
}
let resubmitted = stk(work, &["submit", "--stack"])?;
if !resubmitted.contains("as a stack") {
return Err(format!(
"submit did not re-register after unstack:\n{resubmitted}"
));
}
expect_open_stack(slug, &["ns/one", "ns/two"])?;
stk(work, &["new", "ns/three"])?;
commit(work, "ns-three.txt", "three\n", "ns three")?;
let extended = stk(work, &["submit", "--stack", "--push"])?;
if !extended.contains("extended stack") {
return Err(format!(
"submit did not extend the existing stack:\n{extended}"
));
}
wait_for_review_count(provider, slug, baseline + 3)?;
expect_open_stack(slug, &["ns/one", "ns/two", "ns/three"])?;
let listed = stk(work, &["list"])?;
if !listed.contains('⛁') {
return Err(format!(
"list showed no stack marker for a registered stack - the annotate \
query was rejected and fell back to the per-branch path:\n{listed}"
));
}
stk(work, &["merge", "--all", "--no-wait", "--yes"])?;
wait_for_review_count(provider, slug, baseline)?;
git(work, &["switch", "main"])?;
git(work, &["pull", "--ff-only"])?;
for file in ["ns-one.txt", "ns-two.txt", "ns-three.txt"] {
if !work.join(file).exists() {
return Err(format!("{file} missing on main after the stack landed"));
}
}
Ok(())
}
fn expect_open_stack(slug: &str, branches: &[&str]) -> Result<(), String> {
let Some(stack) = open_stack(slug)? else {
return Err("expected an open stack, found none".to_owned());
};
let heads: Vec<String> = stack
.get("pull_requests")
.and_then(serde_json::Value::as_array)
.map(|reviews| {
reviews
.iter()
.filter_map(|review| review.pointer("/head/ref")?.as_str())
.map(str::to_owned)
.collect()
})
.unwrap_or_default();
if heads != branches {
return Err(format!("stack holds {heads:?}, expected {branches:?}"));
}
Ok(())
}
fn open_stack(slug: &str) -> Result<Option<serde_json::Value>, String> {
let output = gh_json(&["api", &format!("repos/{slug}/stacks")])?;
let listing: serde_json::Value =
serde_json::from_str(&output).map_err(|e| format!("parse stacks: {e}: {output}"))?;
let mut open: Vec<serde_json::Value> = listing
.as_array()
.ok_or_else(|| format!("stacks listing was not an array: {output}"))?
.iter()
.filter(|stack| stack.get("open").and_then(serde_json::Value::as_bool) == Some(true))
.cloned()
.collect();
if open.len() > 1 {
return Err(format!(
"expected at most one open stack, found {}",
open.len()
));
}
Ok(open.pop())
}
fn stacks_enabled(slug: &str) -> Result<bool, String> {
match gh_json(&["api", &format!("repos/{slug}/stacks")]) {
Ok(_) => Ok(true),
Err(error) if error.contains("(HTTP 404)") => Ok(false),
Err(error) => Err(error),
}
}
fn gh_json(args: &[&str]) -> Result<String, String> {
sh("gh", args, None)
}
fn core_lifecycle(provider: Provider, slug: &str, work: &Path) -> Result<(), String> {
stk(work, &["new", "feat/a"])?;
commit(work, "a.txt", "a\n", "a work")?;
stk(work, &["new", "feat/b"])?;
commit(work, "b.txt", "b\n", "b work")?;
stk(work, &["submit", "--stack", "--push"])?;
wait_for_review_count(provider, slug, 2)?;
let listed = stk(work, &["list"])?;
for branch in ["feat/a", "feat/b"] {
if !listed.contains(branch) {
return Err(format!("list did not show {branch}:\n{listed}"));
}
}
if !listed.contains('#') && !listed.contains('!') {
return Err(format!("list showed no review ids:\n{listed}"));
}
stk(work, &["submit", "feat/a", "--title", "e2e retitled"])?;
wait_for_review_title(provider, slug, "feat/a", "e2e retitled")?;
stk(work, &["bottom"])?;
write(work, "a.txt", "a\na2\n")?;
git(work, &["commit", "-am", "a work edit"])?;
stk(work, &["restack", "--push"])?;
let merge_wait = if std::env::var("STK_E2E_WAIT").as_deref() == Ok("true") {
"--wait"
} else {
"--no-wait"
};
stk(work, &["merge", "--all", merge_wait, "--yes"])?;
wait_for_review_count(provider, slug, 0)?;
git(work, &["switch", "main"])?;
git(work, &["pull", "--ff-only"])?;
for file in ["a.txt", "b.txt"] {
if !work.join(file).exists() {
return Err(format!("{file} missing on main after the stack landed"));
}
}
Ok(())
}
fn issue_autoclose(provider: Provider, slug: &str, work: &Path) -> Result<(), String> {
let number = create_issue(provider, slug, "e2e auto-close")?;
let branch = format!("{number}-fix");
stk(work, &["new", &branch])?;
commit(work, "fix.txt", "fix\n", &format!("fix #{number}"))?;
stk(work, &["submit", "--push"])?;
stk(work, &["merge", "-y"])?;
for attempt in 0..15 {
if attempt > 0 {
sleep(Duration::from_secs(2));
}
if issue_state(provider, slug, number)? == "closed" {
return Ok(());
}
}
Err(format!("issue #{number} did not close after merge"))
}
fn metadata_surgery(work: &Path) -> Result<(), String> {
git(work, &["switch", "-c", "loose", "main"])?;
commit(work, "loose.txt", "loose\n", "loose work")?;
stk(work, &["adopt"])?;
expect_parent(work, "loose", "main")?;
stk(work, &["submit", "--push"])?;
git(work, &["config", "--unset", "branch.loose.stkParent"])?;
stk(work, &["repair"])?;
expect_parent(work, "loose", "main")?;
stk(work, &["new", "child"])?;
commit(work, "child.txt", "child\n", "child work")?;
stk(work, &["submit", "--stack", "--push"])?;
stk(work, &["rename", "loose", "relabeled"])?;
expect_parent(work, "child", "relabeled")?;
expect_parent(work, "relabeled", "main")?;
stk(work, &["submit", "--stack", "--push"])?;
Ok(())
}
fn conflict_recovery(work: &Path) -> Result<(), String> {
git(work, &["switch", "main"])?;
stk(work, &["new", "conflict/a"])?;
commit(work, "shared.txt", "original\n", "a base")?;
stk(work, &["new", "conflict/b"])?;
commit(work, "shared.txt", "child-change\n", "b change")?;
stk(work, &["bottom"])?;
write(work, "shared.txt", "parent-change\n")?;
git(work, &["commit", "-am", "a change"])?;
expect_conflict(stk(work, &["restack", "--no-push"]))?;
stk(work, &["abort"])?;
let dirty = git(work, &["status", "--porcelain"])?;
if !dirty.is_empty() {
return Err(format!("working tree not clean after abort:\n{dirty}"));
}
expect_conflict(stk(work, &["restack", "--no-push"]))?;
write(work, "shared.txt", "resolved\n")?;
git(work, &["add", "shared.txt"])?;
stk(work, &["continue"])?;
let child_parent = git(work, &["rev-parse", "conflict/b~1"])?;
let base = git(work, &["rev-parse", "conflict/a"])?;
if child_parent != base {
return Err(format!(
"conflict/b not rebased onto conflict/a after continue ({child_parent} vs {base})"
));
}
Ok(())
}
fn undo_check(work: &Path) -> Result<(), String> {
git(work, &["switch", "main"])?;
stk(work, &["new", "undo/a"])?;
commit(work, "undo-a.txt", "a\n", "undo a")?;
stk(work, &["new", "undo/b"])?;
commit(work, "undo-b.txt", "b\n", "undo b")?;
let before = git(work, &["rev-parse", "undo/b"])?;
stk(work, &["bottom"])?;
write(work, "undo-a.txt", "a\nmore\n")?;
git(work, &["commit", "-am", "more a"])?;
stk(work, &["restack", "--no-push"])?;
let after = git(work, &["rev-parse", "undo/b"])?;
if after == before {
return Err("restack did not move undo/b, so there is nothing to undo".to_owned());
}
stk(work, &["undo"])?;
let restored = git(work, &["rev-parse", "undo/b"])?;
if restored != before {
return Err(format!(
"undo did not restore undo/b ({restored} vs {before})"
));
}
Ok(())
}
fn split_check(work: &Path) -> Result<(), String> {
git(work, &["switch", "main"])?;
stk(work, &["new", "split/work"])?;
commit(work, "s1.txt", "1\n", "split one")?;
commit(work, "s2.txt", "2\n", "split two")?;
stk(work, &["split", "--per-commit"])?;
let parent = git(work, &["config", "--get", "branch.split-one.stkParent"])?;
if parent != "main" {
return Err(format!("split-one parent is {parent:?}, expected main"));
}
let leaf_parent = git(work, &["config", "--get", "branch.split/work.stkParent"])?;
if leaf_parent != "split-one" {
return Err(format!(
"split/work parent is {leaf_parent:?}, expected split-one"
));
}
if git(work, &["rev-parse", "split-one"])? != git(work, &["rev-parse", "split/work~1"])? {
return Err("split-one does not point at split/work's first commit".to_owned());
}
Ok(())
}
fn expect_conflict(result: Result<String, String>) -> Result<(), String> {
match result {
Err(_) => Ok(()),
Ok(output) => Err(format!(
"expected a restack conflict, but it succeeded: {output}"
)),
}
}
fn create_repo(provider: Provider, slug: &str) -> Result<(), String> {
match provider {
Provider::Github => sh(
"gh",
&["repo", "create", slug, "--private", "--add-readme"],
None,
),
Provider::Gitlab => sh(
"glab",
&[
"repo",
"create",
slug,
"--private",
"--readme",
"--defaultBranch",
"main",
],
None,
),
Provider::Gitea => {
let name = slug.split('/').next_back().unwrap_or_default();
let body = format!(
r#"{{"name":"{name}","private":true,"auto_init":true,"default_branch":"main"}}"#
);
sh(
"tea",
&[
"api",
"--login",
&gitea_login(),
"-X",
"POST",
"user/repos",
"-d",
&body,
],
None,
)
}
}
.map(|_| ())
}
fn clone(provider: Provider, slug: &str) -> Result<PathBuf, String> {
let dir = std::env::temp_dir().join(format!("git-stk-e2e-{}", std::process::id()));
let url = match provider {
Provider::Github => format!("https://github.com/{slug}.git"),
Provider::Gitlab => format!("https://gitlab.com/{slug}.git"),
Provider::Gitea => format!("{}/{slug}.git", gitea_base()),
};
let mut last = String::new();
for attempt in 0..5 {
if attempt > 0 {
sleep(Duration::from_secs(2));
}
let _ = std::fs::remove_dir_all(&dir);
match sh("git", &["clone", &url, &dir.to_string_lossy()], None) {
Ok(_) => return Ok(dir),
Err(error) => last = error,
}
}
Err(format!("clone failed after retries: {last}"))
}
fn wait_for_review_count(provider: Provider, slug: &str, want: usize) -> Result<(), String> {
let mut last = None;
for attempt in 0..6 {
if attempt > 0 {
sleep(Duration::from_secs(2));
}
let count = open_review_count(provider, slug)?;
if count == want {
return Ok(());
}
last = Some(count);
}
Err(format!(
"expected {want} open reviews, saw {} after retries",
last.unwrap_or(want)
))
}
fn wait_for_review_title(
provider: Provider,
slug: &str,
branch: &str,
want: &str,
) -> Result<(), String> {
let mut last = String::new();
for attempt in 0..6 {
if attempt > 0 {
sleep(Duration::from_secs(2));
}
last = review_title(provider, slug, branch)?;
if last == want {
return Ok(());
}
}
Err(format!(
"expected the review for {branch} to be titled {want:?}, saw {last:?}"
))
}
fn review_title(provider: Provider, slug: &str, branch: &str) -> Result<String, String> {
let (output, branch_field) = match provider {
Provider::Github => (
sh(
"gh",
&[
"pr",
"list",
"--repo",
slug,
"--state",
"open",
"--json",
"title,headRefName",
],
None,
)?,
"headRefName",
),
Provider::Gitlab => (
sh(
"glab",
&["mr", "list", "-R", slug, "--output", "json"],
None,
)?,
"source_branch",
),
Provider::Gitea => (
sh(
"tea",
&[
"api",
"--login",
&gitea_login(),
&format!("repos/{slug}/pulls?state=open&limit=50"),
],
None,
)?,
"head",
),
};
let value: serde_json::Value =
serde_json::from_str(&output).map_err(|e| format!("parse review list: {e}: {output}"))?;
let head = |review: &serde_json::Value| -> String {
match provider {
Provider::Gitea => review["head"]["ref"]
.as_str()
.unwrap_or_default()
.to_owned(),
_ => review[branch_field].as_str().unwrap_or_default().to_owned(),
}
};
Ok(value
.as_array()
.into_iter()
.flatten()
.find(|review| head(review) == branch)
.and_then(|review| review["title"].as_str())
.unwrap_or_default()
.to_owned())
}
fn open_review_count(provider: Provider, slug: &str) -> Result<usize, String> {
let output = match provider {
Provider::Github => sh(
"gh",
&[
"pr", "list", "--repo", slug, "--state", "open", "--json", "number",
],
None,
)?,
Provider::Gitlab => sh(
"glab",
&["mr", "list", "-R", slug, "--output", "json"],
None,
)?,
Provider::Gitea => sh(
"tea",
&[
"api",
"--login",
&gitea_login(),
&format!("repos/{slug}/pulls?state=open&limit=50"),
],
None,
)?,
};
let value: serde_json::Value =
serde_json::from_str(&output).map_err(|e| format!("parse review list: {e}: {output}"))?;
Ok(value.as_array().map_or(0, Vec::len))
}
fn create_issue(provider: Provider, slug: &str, title: &str) -> Result<u64, String> {
let output = match provider {
Provider::Github => sh(
"gh",
&[
"issue", "create", "--repo", slug, "--title", title, "--body", "e2e",
],
None,
)?,
Provider::Gitlab => sh(
"glab",
&[
"issue",
"create",
"-R",
slug,
"--title",
title,
"--description",
"e2e",
"--yes",
],
None,
)?,
Provider::Gitea => {
let body = format!(r#"{{"title":"{title}","body":"e2e"}}"#);
let output = sh(
"tea",
&[
"api",
"--login",
&gitea_login(),
"-X",
"POST",
&format!("repos/{slug}/issues"),
"-d",
&body,
],
None,
)?;
let value: serde_json::Value = serde_json::from_str(&output)
.map_err(|e| format!("parse issue create: {e}: {output}"))?;
return value["number"]
.as_u64()
.ok_or_else(|| format!("no issue number in create output: {output}"));
}
};
let segment = output.rsplit('/').next().unwrap_or_default();
let digits: String = segment.chars().take_while(char::is_ascii_digit).collect();
digits
.parse()
.map_err(|_| format!("no issue number in create output: {output}"))
}
fn issue_state(provider: Provider, slug: &str, number: u64) -> Result<String, String> {
let output = match provider {
Provider::Github => sh(
"gh",
&["api", &format!("repos/{slug}/issues/{number}")],
None,
)?,
Provider::Gitlab => sh(
"glab",
&[
"api",
&format!("projects/{}/issues/{number}", slug.replace('/', "%2F")),
],
None,
)?,
Provider::Gitea => sh(
"tea",
&[
"api",
"--login",
&gitea_login(),
&format!("repos/{slug}/issues/{number}"),
],
None,
)?,
};
let value: serde_json::Value =
serde_json::from_str(&output).map_err(|e| format!("parse issue: {e}: {output}"))?;
Ok(value["state"].as_str().unwrap_or_default().to_owned())
}
fn expect_parent(work: &Path, branch: &str, want: &str) -> Result<(), String> {
let got = git(
work,
&["config", "--get", &format!("branch.{branch}.stkParent")],
)?;
if got != want {
return Err(format!("{branch} parent is {got:?}, expected {want:?}"));
}
Ok(())
}
struct RepoGuard {
provider: Provider,
slug: String,
}
impl Drop for RepoGuard {
fn drop(&mut self) {
eprintln!("e2e: deleting ephemeral repo {}", self.slug);
let status = match self.provider {
Provider::Github | Provider::Gitlab => Command::new(self.provider.cli())
.args(["repo", "delete", &self.slug, "--yes"])
.status(),
Provider::Gitea => Command::new("tea")
.args([
"api",
"--login",
&gitea_login(),
"-X",
"DELETE",
&format!("repos/{}", self.slug),
])
.status(),
};
if !matches!(status, Ok(s) if s.success()) {
eprintln!(
"e2e: WARNING: failed to delete {} - delete it by hand",
self.slug
);
}
}
}
fn env(key: &str) -> String {
std::env::var(key).unwrap_or_else(|_| panic!("missing required env var {key}"))
}
fn write(dir: &Path, file: &str, contents: &str) -> Result<(), String> {
std::fs::write(dir.join(file), contents).map_err(|e| format!("write {file}: {e}"))
}
fn commit(dir: &Path, file: &str, contents: &str, message: &str) -> Result<(), String> {
write(dir, file, contents)?;
git(dir, &["add", "."])?;
git(dir, &["commit", "-m", message])?;
Ok(())
}
fn git(dir: &Path, args: &[&str]) -> Result<String, String> {
sh("git", args, Some(dir))
}
fn stk(dir: &Path, args: &[&str]) -> Result<String, String> {
let bin = env("GIT_STK_BIN");
sh(&bin, args, Some(dir))
}
fn sh(program: &str, args: &[&str], cwd: Option<&Path>) -> Result<String, String> {
eprintln!("e2e: $ {program} {}", args.join(" "));
let mut command = Command::new(program);
command.args(args);
if let Some(dir) = cwd {
command.current_dir(dir);
}
let output = command
.output()
.map_err(|e| format!("failed to spawn {program}: {e}"))?;
if !output.status.success() {
return Err(format!(
"{program} {} exited {}: {}",
args.join(" "),
output.status,
String::from_utf8_lossy(&output.stderr).trim()
));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}