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,
}
impl Provider {
fn parse(value: &str) -> Result<Self, String> {
match value {
"github" => Ok(Self::Github),
"gitlab" => Ok(Self::Gitlab),
other => Err(format!("unknown STK_E2E_PROVIDER {other:?}")),
}
}
fn cli(self) -> &'static str {
match self {
Self::Github => "gh",
Self::Gitlab => "glab",
}
}
fn host(self) -> &'static str {
match self {
Self::Github => "github.com",
Self::Gitlab => "gitlab.com",
}
}
}
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"])?;
core_lifecycle(provider, &slug, work)?;
issue_autoclose(provider, &slug, work)?;
metadata_surgery(work)?;
conflict_recovery(work)?;
undo_check(work)?;
Ok(())
}
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)?;
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..5 {
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 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,
),
}
.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 = format!("https://{}/{slug}.git", provider.host());
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 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,
)?,
};
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,
)?,
};
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,
)?,
};
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 = Command::new(self.provider.cli())
.args(["repo", "delete", &self.slug, "--yes"])
.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())
}