use assert_cmd::Command;
use chrono::{Duration, Utc};
use predicates::str::contains;
use predicates::str::is_match;
use serial_test::serial;
mod util;
use util::setup_temp_git_repo;
#[test]
#[serial]
fn test_status_command() {
let (_dir, _bare_dir, repo_path) = setup_temp_git_repo();
std::env::set_current_dir(&repo_path).unwrap();
let mut cmd = Command::cargo_bin("tbdflow").unwrap();
cmd.arg("--verbose").arg("status");
cmd.assert().success().stdout(contains("Checking status"));
}
#[test]
#[serial]
fn test_current_branch_command() {
let (_dir, _bare_dir, repo_path) = setup_temp_git_repo();
std::env::set_current_dir(&repo_path).unwrap();
let mut cmd = Command::cargo_bin("tbdflow").unwrap();
cmd.arg("current-branch");
cmd.assert()
.success()
.stdout(contains("Current branch is:"));
}
#[test]
#[serial]
fn test_branch_command() {
let (_dir, _bare_dir, repo_path) = setup_temp_git_repo();
std::env::set_current_dir(&repo_path).unwrap();
let config_content = r#"
main_branch_name: main
stale_branch_threshold_days: 1
issue_handling:
strategy: "branch-name"
branch_types:
feat: "feat/"
fix: "fix/"
branch_prefixes:
feature: "feature_"
release: "release_"
hotfix: "hotfix_"
automatic_tags:
release_prefix: "v"
hotfix_prefix: "hotfix-tag_"
"#;
std::fs::write(repo_path.join(".tbdflow.yml"), config_content).unwrap();
std::process::Command::new("git")
.args(&["add", ".tbdflow.yml"])
.current_dir(&repo_path)
.output()
.unwrap();
std::process::Command::new("git")
.args(&["commit", "-m", "Add tbdflow config"])
.current_dir(&repo_path)
.output()
.unwrap();
let mut cmd_with_issue = Command::cargo_bin("tbdflow").unwrap();
cmd_with_issue
.arg("branch")
.arg("--type")
.arg("feat")
.arg("--name")
.arg("new-dashboard")
.arg("--issue")
.arg("PROJ-123");
cmd_with_issue.assert().success().stdout(contains(
"Success! Switched to new branch: 'feat/PROJ-123-new-dashboard'",
));
let (_dir2, _bare_dir2, repo_path2) = setup_temp_git_repo();
std::env::set_current_dir(&repo_path2).unwrap();
let config_content_2 = r#"
main_branch_name: main
stale_branch_threshold_days: 1
issue_handling:
strategy: "commit-scope"
branch_types:
feat: "feat/"
fix: "fix/"
branch_prefixes:
feature: "feature_"
release: "release_"
hotfix: "hotfix_"
automatic_tags:
release_prefix: "v"
hotfix_prefix: "hotfix-tag_"
"#;
std::fs::write(repo_path2.join(".tbdflow.yml"), config_content_2).unwrap();
std::process::Command::new("git")
.args(&["add", ".tbdflow.yml"])
.current_dir(&repo_path2)
.output()
.unwrap();
std::process::Command::new("git")
.args(&["commit", "-m", "Add tbdflow config"])
.current_dir(&repo_path2)
.output()
.unwrap();
let mut cmd_without_issue = Command::cargo_bin("tbdflow").unwrap();
cmd_without_issue
.arg("branch")
.arg("--type")
.arg("fix")
.arg("--name")
.arg("login-bug")
.arg("--issue")
.arg("PROJ-456");
cmd_without_issue
.assert()
.success()
.stdout(contains("Success! Switched to new branch: 'fix/login-bug'"));
}
#[test]
#[serial]
fn test_create_feature_branch_command() {
let (_dir, _bare_dir, repo_path) = setup_temp_git_repo();
std::env::set_current_dir(&repo_path).unwrap();
let mut cmd = Command::cargo_bin("tbdflow").unwrap();
cmd.arg("branch")
.arg("--type")
.arg("feature")
.arg("--name")
.arg("new-feature");
cmd.assert().success().stdout(contains(
"Success! Switched to new branch: 'feature_new-feature'",
));
}
#[test]
#[serial]
fn test_create_release_branch_command() {
let (_dir, _bare_dir, repo_path) = setup_temp_git_repo();
std::env::set_current_dir(&repo_path).unwrap();
let mut cmd = Command::cargo_bin("tbdflow").unwrap();
cmd.arg("branch")
.arg("--type")
.arg("release")
.arg("--name")
.arg("1.0.0");
cmd.assert()
.success()
.stdout(contains("Success! Switched to new branch: 'release_1.0.0'"));
}
#[test]
#[serial]
fn test_commit_command() {
let (_dir, _bare_dir, repo_path) = setup_temp_git_repo();
std::env::set_current_dir(&repo_path).unwrap();
std::fs::write(repo_path.join("BUTTON.md"), "this is a new button â– ").unwrap();
Command::new("git")
.arg("add")
.arg("BUTTON.md")
.current_dir(&repo_path)
.output()
.unwrap();
let mut retries = 5;
while retries > 0 {
let status = Command::new("git")
.args(&["status", "--porcelain"])
.current_dir(&repo_path)
.output()
.unwrap();
if status.stdout.is_empty() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
retries -= 1;
}
let mut cmd = Command::cargo_bin("tbdflow").unwrap();
cmd.arg("commit")
.arg("--type")
.arg("feat")
.arg("--scope")
.arg("ui")
.arg("--message")
.arg("add new button")
.arg("--body")
.arg("This button is used for submitting forms.")
.arg("--breaking")
.arg("--tag")
.arg("button-v1");
cmd.assert().success().stdout(
is_match(r"Successfully (?:committed and )?pushed changes to '?(?:main|master)'?\.")
.unwrap(),
);
let output = std::process::Command::new("git")
.arg("tag")
.current_dir(&repo_path)
.output()
.unwrap();
let tags = String::from_utf8_lossy(&output.stdout);
assert!(
tags.contains("button-v1"),
"Expected tag button-v1 not found. Tags: {}",
tags
);
}
#[test]
#[serial]
fn test_complete_feature_branch_command() {
let (_dir, _bare_dir, repo_path) = setup_temp_git_repo();
std::env::set_current_dir(&repo_path).unwrap();
let mut create_cmd = Command::cargo_bin("tbdflow").unwrap();
create_cmd
.arg("branch")
.arg("--type")
.arg("feature")
.arg("--name")
.arg("new-feature");
create_cmd.assert().success();
let mut cmd = Command::cargo_bin("tbdflow").unwrap();
cmd.arg("complete")
.arg("--type")
.arg("feature")
.arg("--name")
.arg("new-feature");
cmd.assert()
.success()
.stdout(contains("Branch to complete: feature_new-feature"));
}
#[test]
#[serial]
fn test_complete_release_branch_command() {
let (_dir, _bare_dir, repo_path) = setup_temp_git_repo();
std::env::set_current_dir(&repo_path).unwrap();
let mut create_cmd = Command::cargo_bin("tbdflow").unwrap();
create_cmd
.arg("branch")
.arg("--type")
.arg("release")
.arg("--name")
.arg("1.0.0");
create_cmd.assert().success();
let output = std::process::Command::new("git")
.args(&["branch", "--list"])
.current_dir(&repo_path)
.output()
.unwrap();
let branches = String::from_utf8_lossy(&output.stdout);
assert!(
branches.contains("release_1.0.0"),
"Expected branch release_1.0.0 not found. Branches: {}",
branches
);
let mut cmd = Command::cargo_bin("tbdflow").unwrap();
cmd.arg("--verbose")
.arg("complete")
.arg("--type")
.arg("release")
.arg("--name")
.arg("1.0.0");
cmd.assert()
.success()
.stdout(contains("Branch to complete: release_1.0.0"));
let output = std::process::Command::new("git")
.arg("tag")
.current_dir(&repo_path)
.output()
.unwrap();
let tags = String::from_utf8_lossy(&output.stdout);
assert!(
tags.contains("v1.0.0"),
"Expected tag v1.0.0 not found. Tags: {}",
tags
);
}
#[test]
#[serial]
fn test_sync_command() {
let (_dir, bare_dir, repo_path) = setup_temp_git_repo();
let remote_repo_url = bare_dir.path().to_str().unwrap();
let second_clone_dir = tempfile::tempdir().unwrap();
std::process::Command::new("git")
.args(&["clone", remote_repo_url, "."])
.current_dir(second_clone_dir.path())
.output()
.unwrap();
std::fs::write(second_clone_dir.path().join("REMOTE.md"), "remote change").unwrap();
std::process::Command::new("git")
.args(&["config", "user.email", "teammate@example.com"])
.current_dir(second_clone_dir.path())
.output()
.unwrap();
std::process::Command::new("git")
.args(&["config", "user.name", "Teammate"])
.current_dir(second_clone_dir.path())
.output()
.unwrap();
std::process::Command::new("git")
.args(&["add", "."])
.current_dir(second_clone_dir.path())
.output()
.unwrap();
std::process::Command::new("git")
.args(&["commit", "-m", "feat: add remote file"])
.current_dir(second_clone_dir.path())
.output()
.unwrap();
std::process::Command::new("git")
.args(&["push"])
.current_dir(second_clone_dir.path())
.output()
.unwrap();
std::env::set_current_dir(&repo_path).unwrap();
let mut cmd = Command::cargo_bin("tbdflow").unwrap();
cmd.arg("sync");
cmd.assert()
.success()
.stdout(contains("Syncing with remote"));
}
#[test]
#[serial]
fn test_undo_command() {
let (_dir, _bare_dir, repo_path) = setup_temp_git_repo();
std::env::set_current_dir(&repo_path).unwrap();
std::fs::write(repo_path.join("BAD_CHANGE.md"), "this breaks the build").unwrap();
std::process::Command::new("git")
.args(&["add", "."])
.current_dir(&repo_path)
.output()
.unwrap();
std::process::Command::new("git")
.args(&["commit", "-m", "feat: add bad change"])
.current_dir(&repo_path)
.output()
.unwrap();
std::process::Command::new("git")
.args(&["push"])
.current_dir(&repo_path)
.output()
.unwrap();
let sha_output = Command::cargo_bin("tbdflow")
.unwrap()
.arg("head-sha")
.output()
.unwrap();
let sha = String::from_utf8_lossy(&sha_output.stdout)
.trim()
.to_string();
assert!(repo_path.join("BAD_CHANGE.md").exists());
let mut cmd = Command::cargo_bin("tbdflow").unwrap();
cmd.arg("undo").arg(&sha);
cmd.assert()
.success()
.stdout(contains("Undo: The Panic Button"))
.stdout(contains(&format!("Commit '{}' has been reverted", &sha)));
assert!(
!repo_path.join("BAD_CHANGE.md").exists(),
"BAD_CHANGE.md should have been removed by the revert"
);
}
#[test]
#[serial]
fn test_undo_no_push_command() {
let (_dir, _bare_dir, repo_path) = setup_temp_git_repo();
std::env::set_current_dir(&repo_path).unwrap();
std::fs::write(repo_path.join("OOPS.md"), "oops").unwrap();
std::process::Command::new("git")
.args(&["add", "."])
.current_dir(&repo_path)
.output()
.unwrap();
std::process::Command::new("git")
.args(&["commit", "-m", "feat: oops"])
.current_dir(&repo_path)
.output()
.unwrap();
std::process::Command::new("git")
.args(&["push"])
.current_dir(&repo_path)
.output()
.unwrap();
let sha_output = Command::cargo_bin("tbdflow")
.unwrap()
.arg("head-sha")
.output()
.unwrap();
let sha = String::from_utf8_lossy(&sha_output.stdout)
.trim()
.to_string();
let mut cmd = Command::cargo_bin("tbdflow").unwrap();
cmd.arg("undo").arg(&sha).arg("--no-push");
cmd.assert()
.success()
.stdout(contains("Revert commit created locally (--no-push)"));
assert!(!repo_path.join("OOPS.md").exists());
}
#[test]
#[serial]
fn test_undo_nonexistent_sha() {
let (_dir, _bare_dir, repo_path) = setup_temp_git_repo();
std::env::set_current_dir(&repo_path).unwrap();
let mut cmd = Command::cargo_bin("tbdflow").unwrap();
cmd.arg("undo").arg("deadbeefdeadbeef");
cmd.assert().failure();
}
#[test]
#[serial]
fn test_check_branches_command() {
let (_dir, _bare_dir, repo_path) = setup_temp_git_repo();
std::env::set_current_dir(&repo_path).unwrap();
let mut create_cmd = Command::cargo_bin("tbdflow").unwrap();
create_cmd
.arg("branch")
.arg("--type")
.arg("feature")
.arg("--name")
.arg("stale-feature");
create_cmd.assert().success();
let old_date = (Utc::now() - Duration::hours(25)).to_rfc3339();
std::process::Command::new("git")
.args(&["commit", "--allow-empty", "-m", "stale commit"])
.env("GIT_COMMITTER_DATE", &old_date) .current_dir(&repo_path)
.output()
.unwrap();
std::process::Command::new("git")
.args(&["checkout", "main"])
.current_dir(&repo_path)
.output()
.unwrap();
let mut cmd = Command::cargo_bin("tbdflow").unwrap();
cmd.arg("check-branches");
cmd.assert()
.success()
.stdout(contains("Warning: The following branches may be stale:"))
.stdout(contains("feature_stale-feature"));
}
#[test]
#[serial]
fn test_radar_disabled_by_default() {
let (_dir, _bare_dir, repo_path) = setup_temp_git_repo();
std::env::set_current_dir(&repo_path).unwrap();
let config_content = r#"
main_branch_name: main
stale_branch_threshold_days: 1
branch_types:
feat: "feat/"
automatic_tags:
release_prefix: "v"
"#;
std::fs::write(repo_path.join(".tbdflow.yml"), config_content).unwrap();
let mut cmd = Command::cargo_bin("tbdflow").unwrap();
cmd.arg("radar");
cmd.assert().success().stdout(contains("Radar is disabled"));
}
#[test]
#[serial]
fn test_radar_no_overlaps() {
let (_dir, _bare_dir, repo_path) = setup_temp_git_repo();
std::env::set_current_dir(&repo_path).unwrap();
let config_content = r#"
main_branch_name: main
stale_branch_threshold_days: 1
radar:
enabled: true
level: file
branch_types:
feat: "feat/"
automatic_tags:
release_prefix: "v"
"#;
std::fs::write(repo_path.join(".tbdflow.yml"), config_content).unwrap();
std::process::Command::new("git")
.args(&["add", ".tbdflow.yml"])
.current_dir(&repo_path)
.output()
.unwrap();
std::process::Command::new("git")
.args(&["commit", "-m", "chore: add config"])
.current_dir(&repo_path)
.output()
.unwrap();
std::process::Command::new("git")
.args(&["push"])
.current_dir(&repo_path)
.output()
.unwrap();
std::fs::write(repo_path.join("README.md"), "modified locally").unwrap();
let mut cmd = Command::cargo_bin("tbdflow").unwrap();
cmd.arg("radar");
cmd.assert()
.success()
.stdout(contains("No overlaps detected"));
}
#[test]
#[serial]
fn test_radar_detects_file_overlap() {
let (_dir, _bare_dir, repo_path) = setup_temp_git_repo();
std::env::set_current_dir(&repo_path).unwrap();
let config_content = r#"
main_branch_name: main
stale_branch_threshold_days: 1
radar:
enabled: true
level: file
branch_types:
feat: "feat/"
automatic_tags:
release_prefix: "v"
"#;
std::fs::write(repo_path.join(".tbdflow.yml"), config_content).unwrap();
std::process::Command::new("git")
.args(&["add", ".tbdflow.yml"])
.current_dir(&repo_path)
.output()
.unwrap();
std::process::Command::new("git")
.args(&["commit", "-m", "chore: add config"])
.current_dir(&repo_path)
.output()
.unwrap();
std::process::Command::new("git")
.args(&["push"])
.current_dir(&repo_path)
.output()
.unwrap();
std::process::Command::new("git")
.args(&["checkout", "-b", "feat/other-work"])
.current_dir(&repo_path)
.output()
.unwrap();
std::fs::write(repo_path.join("README.md"), "changed by teammate").unwrap();
std::process::Command::new("git")
.args(&["add", "."])
.current_dir(&repo_path)
.output()
.unwrap();
std::process::Command::new("git")
.args(&["commit", "-m", "feat: teammate changes"])
.current_dir(&repo_path)
.output()
.unwrap();
std::process::Command::new("git")
.args(&["push", "-u", "origin", "feat/other-work"])
.current_dir(&repo_path)
.output()
.unwrap();
std::process::Command::new("git")
.args(&["checkout", "main"])
.current_dir(&repo_path)
.output()
.unwrap();
std::fs::write(repo_path.join("README.md"), "my local change").unwrap();
let mut cmd = Command::cargo_bin("tbdflow").unwrap();
cmd.arg("radar");
cmd.assert()
.success()
.stdout(contains("OVERLAP DETECTED"))
.stdout(contains("README.md"))
.stdout(contains("feat/other-work"));
}