use std::fs;
use std::path::{Path, PathBuf};
use assert_cmd::Command;
use predicates::prelude::*;
use serial_test::serial;
use tempfile::TempDir;
use git_paw::tmux::{
PaneSpec, TmuxSessionBuilder, attach, ensure_tmux_installed, is_session_alive, kill_session,
};
fn cmd() -> Command {
Command::cargo_bin("git-paw").expect("binary exists")
}
struct TestRepo {
_sandbox: TempDir,
repo: PathBuf,
}
impl TestRepo {
fn path(&self) -> &Path {
&self.repo
}
}
fn setup_test_repo() -> TestRepo {
let sandbox = TempDir::new().expect("create temp dir");
let repo = sandbox.path().join("test-repo");
fs::create_dir_all(&repo).expect("create repo dir");
run_git(&repo, &["init"]);
run_git(&repo, &["config", "user.email", "test@test.com"]);
run_git(&repo, &["config", "user.name", "Test"]);
let readme = repo.join("README.md");
fs::write(&readme, "# Test repo").expect("write README");
run_git(&repo, &["add", "."]);
run_git(&repo, &["commit", "-m", "initial commit"]);
TestRepo {
_sandbox: sandbox,
repo,
}
}
fn run_git(dir: &Path, args: &[&str]) {
let output = std::process::Command::new("git")
.current_dir(dir)
.args(args)
.output()
.expect("run git command");
assert!(
output.status.success(),
"git {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn dry_run_with_flags_shows_plan() {
let tr = setup_test_repo();
run_git(tr.path(), &["branch", "feat/a"]);
run_git(tr.path(), &["branch", "feat/b"]);
let config = tr.path().join(".git-paw").join("config.toml");
fs::create_dir_all(config.parent().unwrap()).expect("create config dir");
fs::write(&config, "[clis.echo]\ncommand = \"/bin/echo\"\n").expect("write config");
cmd()
.current_dir(tr.path())
.args([
"start",
"--dry-run",
"--cli",
"echo",
"--branches",
"feat/a,feat/b",
])
.assert()
.success()
.stdout(
predicate::str::contains("Dry run")
.and(predicate::str::contains("feat/a"))
.and(predicate::str::contains("feat/b"))
.and(predicate::str::contains("echo")),
);
}
#[test]
fn preset_not_found_returns_error() {
let tr = setup_test_repo();
cmd()
.current_dir(tr.path())
.args(["start", "--preset", "nonexistent"])
.assert()
.failure()
.stderr(predicate::str::contains("not found"));
}
#[test]
fn stop_with_no_session() {
let tr = setup_test_repo();
cmd()
.current_dir(tr.path())
.arg("stop")
.assert()
.success()
.stdout(predicate::str::contains("No active session"));
}
#[test]
fn purge_with_no_session() {
let tr = setup_test_repo();
cmd()
.current_dir(tr.path())
.args(["purge", "--force"])
.assert()
.success()
.stdout(predicate::str::contains("No session to purge"));
}
#[test]
fn status_with_no_session() {
let tr = setup_test_repo();
cmd()
.current_dir(tr.path())
.arg("status")
.assert()
.success()
.stdout(predicate::str::contains("No session"));
}
#[test]
fn stop_from_non_git_dir_fails() {
let tmp = TempDir::new().expect("create temp dir");
cmd()
.current_dir(tmp.path())
.arg("stop")
.assert()
.failure()
.stderr(predicate::str::contains("Not a git repository"));
}
#[test]
fn status_from_non_git_dir_fails() {
let tmp = TempDir::new().expect("create temp dir");
cmd()
.current_dir(tmp.path())
.arg("status")
.assert()
.failure()
.stderr(predicate::str::contains("Not a git repository"));
}
#[test]
fn init_creates_git_paw_dir() {
let tr = setup_test_repo();
cmd()
.current_dir(tr.path())
.arg("init")
.assert()
.success()
.stdout(
predicate::str::contains("Created .git-paw/")
.and(predicate::str::contains("Initialized git-paw")),
);
assert!(tr.path().join(".git-paw").is_dir());
assert!(tr.path().join(".git-paw/config.toml").exists());
assert!(tr.path().join(".git-paw/logs").is_dir());
assert!(!tr.path().join("AGENTS.md").exists());
}
#[test]
fn init_outside_git_repo_fails() {
let tmp = TempDir::new().expect("create temp dir");
cmd()
.current_dir(tmp.path())
.arg("init")
.assert()
.failure()
.stderr(predicate::str::contains("Not a git repository"));
}
#[test]
fn init_is_idempotent() {
let tr = setup_test_repo();
cmd().current_dir(tr.path()).arg("init").assert().success();
cmd()
.current_dir(tr.path())
.arg("init")
.assert()
.success()
.stdout(predicate::str::contains("Already initialized"));
}
#[test]
fn from_specs_no_specs_config_returns_error() {
let tr = setup_test_repo();
let config = tr.path().join(".git-paw").join("config.toml");
fs::create_dir_all(config.parent().unwrap()).expect("create config dir");
fs::write(&config, "# no specs section\n").expect("write config");
cmd()
.current_dir(tr.path())
.args(["start", "--from-specs"])
.assert()
.failure()
.stderr(predicate::str::contains("[specs]"));
}
#[test]
fn from_specs_dry_run_with_valid_specs_shows_plan() {
let tr = setup_test_repo();
let paw_dir = tr.path().join(".git-paw");
fs::create_dir_all(&paw_dir).expect("create .git-paw");
fs::write(
paw_dir.join("config.toml"),
"[specs]\ndir = \"openspec/changes\"\ntype = \"openspec\"\n",
)
.expect("write config");
let config_content = fs::read_to_string(paw_dir.join("config.toml")).unwrap();
fs::write(
paw_dir.join("config.toml"),
format!("{config_content}\n[clis.echo]\ncommand = \"/bin/echo\"\n"),
)
.expect("append cli");
let spec_dir = tr.path().join("openspec/changes/add-auth");
fs::create_dir_all(&spec_dir).expect("create spec dir");
fs::write(spec_dir.join("tasks.md"), "Implement authentication\n").expect("write tasks.md");
cmd()
.current_dir(tr.path())
.args(["start", "--from-specs", "--dry-run", "--cli", "echo"])
.assert()
.success()
.stdout(
predicate::str::contains("Dry run")
.and(predicate::str::contains("spec/add-auth"))
.and(predicate::str::contains("echo")),
);
}
#[test]
fn from_specs_empty_specs_dir_prints_no_pending() {
let tr = setup_test_repo();
let paw_dir = tr.path().join(".git-paw");
fs::create_dir_all(&paw_dir).expect("create .git-paw");
fs::write(
paw_dir.join("config.toml"),
"[specs]\ndir = \"openspec/changes\"\ntype = \"openspec\"\n",
)
.expect("write config");
fs::create_dir_all(tr.path().join("openspec/changes")).expect("create specs dir");
cmd()
.current_dir(tr.path())
.args(["start", "--from-specs"])
.assert()
.success()
.stdout(predicate::str::contains("No pending specs"));
}
#[test]
fn replay_list_with_no_logs_shows_message() {
let tr = setup_test_repo();
cmd()
.current_dir(tr.path())
.args(["replay", "--list"])
.assert()
.success()
.stdout(predicate::str::contains("No log sessions"));
}
#[test]
fn replay_nonexistent_branch_shows_error() {
let tr = setup_test_repo();
let log_dir = tr.path().join(".git-paw/logs/paw-test");
fs::create_dir_all(&log_dir).expect("create log dir");
fs::write(log_dir.join("main.log"), "some log content").expect("write log");
cmd()
.current_dir(tr.path())
.args(["replay", "nonexistent-branch"])
.assert()
.failure()
.stderr(predicate::str::contains("nonexistent-branch"));
}
fn cleanup_session(name: &str) {
let _ = kill_session(name);
}
#[test]
#[serial]
fn tmux_session_create_and_kill_lifecycle() {
ensure_tmux_installed().expect("tmux must be installed to run this test");
let session_name = "paw-e2e-lifecycle-test";
cleanup_session(session_name);
let tmp = TempDir::new().expect("create temp dir");
let worktree_path = tmp.path().to_string_lossy().to_string();
let session = TmuxSessionBuilder::new("e2e-lifecycle-test")
.add_pane(PaneSpec {
branch: "feat/auth".into(),
worktree: worktree_path.clone(),
cli_command: "echo hello".into(),
})
.build()
.expect("build session");
session.execute().expect("execute session");
assert!(
is_session_alive(session_name).expect("check session"),
"session should be alive after creation"
);
kill_session(session_name).expect("kill session");
assert!(
!is_session_alive(session_name).expect("check session"),
"session should be dead after kill"
);
}
#[test]
#[serial]
fn tmux_session_with_five_panes_and_different_clis() {
ensure_tmux_installed().expect("tmux must be installed to run this test");
let session_name = "paw-e2e-multipane-test";
cleanup_session(session_name);
let tmp = TempDir::new().expect("create temp dir");
let worktree = tmp.path().to_string_lossy().to_string();
let panes = vec![
("feat/auth", "claude"),
("feat/api", "codex"),
("fix/db", "gemini"),
("feat/ui", "aider"),
("refactor/cache", "amp"),
];
let mut builder = TmuxSessionBuilder::new("e2e-multipane-test");
for (branch, cli) in &panes {
builder = builder.add_pane(PaneSpec {
branch: (*branch).into(),
worktree: worktree.clone(),
cli_command: (*cli).into(),
});
}
let session = builder.build().expect("build session");
session.execute().expect("execute session");
assert!(
is_session_alive(session_name).expect("check session"),
"5-pane session should be alive"
);
let output = std::process::Command::new("tmux")
.args(["list-panes", "-t", session_name, "-F", "#{pane_index}"])
.output()
.expect("list panes");
let pane_count = String::from_utf8_lossy(&output.stdout).lines().count();
assert_eq!(pane_count, 5, "session should have 5 panes");
let output = std::process::Command::new("tmux")
.args(["list-panes", "-t", session_name, "-F", "#{pane_title}"])
.output()
.expect("list pane titles");
let titles: Vec<String> = String::from_utf8_lossy(&output.stdout)
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect();
assert_eq!(titles.len(), 5, "should have 5 pane titles");
for (i, (branch, cli)) in panes.iter().enumerate() {
assert!(
titles[i].contains(branch) && titles[i].contains(cli),
"pane {i} should map {branch} to {cli}, got: {}",
titles[i]
);
}
cleanup_session(session_name);
}
#[test]
#[serial]
fn tmux_mouse_mode_enabled_by_default() {
ensure_tmux_installed().expect("tmux must be installed to run this test");
let session_name = "paw-e2e-mouse-test";
cleanup_session(session_name);
let tmp = TempDir::new().expect("create temp dir");
let worktree = tmp.path().to_string_lossy().to_string();
let session = TmuxSessionBuilder::new("e2e-mouse-test")
.add_pane(PaneSpec {
branch: "feat/test".into(),
worktree,
cli_command: "echo hi".into(),
})
.build()
.expect("build session");
session.execute().expect("execute session");
let output = std::process::Command::new("tmux")
.args(["show-option", "-t", session_name, "mouse"])
.output()
.expect("show mouse option");
let mouse_setting = String::from_utf8_lossy(&output.stdout);
assert!(
mouse_setting.contains("on"),
"mouse should be enabled by default, got: {mouse_setting}"
);
cleanup_session(session_name);
}
#[test]
#[serial]
fn tmux_is_session_alive_returns_false_for_nonexistent() {
ensure_tmux_installed().expect("tmux must be installed to run this test");
let alive = is_session_alive("paw-definitely-does-not-exist-xyz").expect("check session");
assert!(!alive);
}
#[test]
fn error_exit_code_is_1_for_not_a_git_repo() {
let tmp = TempDir::new().expect("create temp dir");
cmd()
.current_dir(tmp.path())
.arg("start")
.assert()
.code(1)
.stderr(predicate::str::contains("Not a git repository"));
}
#[test]
fn error_exit_code_is_1_for_preset_not_found() {
let tr = setup_test_repo();
cmd()
.current_dir(tr.path())
.args(["start", "--preset", "nonexistent"])
.assert()
.code(1)
.stderr(predicate::str::contains("not found"));
}
#[test]
#[serial]
fn attach_fails_for_nonexistent_session() {
ensure_tmux_installed().expect("tmux must be installed to run this test");
let result = attach("paw-e2e-attach-nonexistent-xyz");
assert!(result.is_err(), "attach to nonexistent session should fail");
}
#[test]
fn replay_strips_ansi_from_log() {
let tr = setup_test_repo();
let log_dir = tr.path().join(".git-paw/logs/paw-test");
fs::create_dir_all(&log_dir).expect("create log dir");
fs::write(log_dir.join("main.log"), "\x1b[31mred\x1b[0m plain").expect("write log with ANSI");
cmd()
.current_dir(tr.path())
.args(["replay", "main", "--session", "paw-test"])
.assert()
.success()
.stdout(
predicate::str::contains("red plain")
.and(predicate::str::contains("\x1b[31m").not())
.and(predicate::str::contains("\x1b[0m").not()),
);
}
#[test]
fn replay_list_shows_sessions_and_branches() {
let tr = setup_test_repo();
let log_dir = tr.path().join(".git-paw/logs/paw-test");
fs::create_dir_all(&log_dir).expect("create log dir");
fs::write(log_dir.join("feat--auth.log"), "auth log").expect("write auth log");
fs::write(log_dir.join("main.log"), "main log").expect("write main log");
cmd()
.current_dir(tr.path())
.args(["replay", "--list"])
.assert()
.success()
.stdout(
predicate::str::contains("paw-test")
.and(predicate::str::contains("2 branches"))
.and(predicate::str::contains("feat/auth"))
.and(predicate::str::contains("main")),
);
}
#[test]
fn from_specs_markdown_format_dry_run() {
let tr = setup_test_repo();
let paw_dir = tr.path().join(".git-paw");
fs::create_dir_all(&paw_dir).expect("create .git-paw");
fs::write(
paw_dir.join("config.toml"),
"[specs]\ndir = \"specs\"\ntype = \"markdown\"\n\n[clis.echo]\ncommand = \"/bin/echo\"\n",
)
.expect("write config");
let specs_dir = tr.path().join("specs");
fs::create_dir_all(&specs_dir).expect("create specs dir");
fs::write(
specs_dir.join("add-auth.md"),
"---\npaw_status: pending\n---\nImplement authentication\n",
)
.expect("write spec");
cmd()
.current_dir(tr.path())
.args(["start", "--from-specs", "--dry-run", "--cli", "echo"])
.assert()
.success()
.stdout(
predicate::str::contains("Dry run")
.and(predicate::str::contains("add-auth"))
.and(predicate::str::contains("echo")),
);
}
#[test]
fn from_specs_skips_done_markdown_specs() {
let tr = setup_test_repo();
let paw_dir = tr.path().join(".git-paw");
fs::create_dir_all(&paw_dir).expect("create .git-paw");
fs::write(
paw_dir.join("config.toml"),
"[specs]\ndir = \"specs\"\ntype = \"markdown\"\n\n[clis.echo]\ncommand = \"/bin/echo\"\n",
)
.expect("write config");
let specs_dir = tr.path().join("specs");
fs::create_dir_all(&specs_dir).expect("create specs dir");
fs::write(
specs_dir.join("add-auth.md"),
"---\npaw_status: done\n---\nAlready implemented\n",
)
.expect("write spec");
cmd()
.current_dir(tr.path())
.args(["start", "--from-specs"])
.assert()
.success()
.stdout(predicate::str::contains("No pending specs"));
}
#[test]
#[serial]
fn attach_succeeds_for_live_session() {
ensure_tmux_installed().expect("tmux must be installed to run this test");
let session_name = "paw-e2e-attach-test";
cleanup_session(session_name);
let session = TmuxSessionBuilder::new("e2e-attach-test")
.add_pane(PaneSpec {
branch: "main".into(),
worktree: "/tmp".into(),
cli_command: "echo attached".into(),
})
.build()
.expect("build session");
session.execute().expect("execute session");
let name = session_name.to_string();
let detacher = std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(500));
let _ = std::process::Command::new("tmux")
.args(["detach-client", "-s", &name])
.output();
});
let result = attach(session_name);
detacher.join().expect("detacher thread");
if result.is_ok() {
} else {
eprintln!("note: attach returned error (expected in headless environments)");
}
cleanup_session(session_name);
}