Files
QuiCommit/tests/integration_tests.rs
SidneyZhang fac96021fb feat(commit,tag): 推送环节感知远程配置并支持多远程选择推送
- 仓库未配置任何远程时跳过推送询问并打印说明(--push 显式指定同样跳过)
- --remote 指定的目标不存在时回退到第一个可用远程(字母序)并明确提示
- 新增 --select-remote:多远程仓库弹出多选列表依次推送,失败即中止并汇总已推送/未尝试
- 优先级链:--yes > --select-remote > --push
- commit/tag 推送段收敛为 commands::run_push_flow 共享流程
- GitRepo 新增 list_remotes/resolve_push_target,i18n 新文案覆盖 7 种语言
2026-08-31 14:19:58 +08:00

1599 lines
48 KiB
Rust

use assert_cmd::cargo::cargo_bin_cmd;
use predicates::prelude::*;
use std::fs;
use std::path::PathBuf;
use tempfile::TempDir;
fn create_git_repo(dir: &PathBuf) -> std::process::Output {
std::process::Command::new("git")
.args(&["init"])
.current_dir(dir)
.output()
.expect("Failed to init git repo")
}
fn configure_git_user(dir: &PathBuf) {
std::process::Command::new("git")
.args(&["config", "user.name", "Test User"])
.current_dir(dir)
.output()
.expect("Failed to configure git user name");
std::process::Command::new("git")
.args(&["config", "user.email", "test@example.com"])
.current_dir(dir)
.output()
.expect("Failed to configure git user email");
}
fn create_test_file(dir: &PathBuf, name: &str, content: &str) {
let file_path = dir.join(name);
fs::write(&file_path, content).expect("Failed to create test file");
}
fn stage_file(dir: &PathBuf, name: &str) {
std::process::Command::new("git")
.args(&["add", name])
.current_dir(dir)
.output()
.expect("Failed to stage file");
}
fn create_commit(dir: &PathBuf, message: &str) {
std::process::Command::new("git")
.args(&["commit", "-m", message])
.current_dir(dir)
.output()
.expect("Failed to create commit");
}
fn setup_git_repo(dir: &PathBuf) {
create_git_repo(dir);
configure_git_user(dir);
}
fn setup_test_repo_with_file(dir: &PathBuf, file_name: &str, file_content: &str) {
setup_git_repo(dir);
create_test_file(dir, file_name, file_content);
stage_file(dir, file_name);
}
fn init_quicommit(dir: &PathBuf, config_path: &PathBuf) {
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()])
.current_dir(dir);
cmd.assert().success();
}
mod cli_basic {
use super::*;
#[test]
fn test_help() {
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.arg("--help");
cmd.assert()
.success()
.stdout(predicate::str::contains("QuiCommit"))
.stdout(predicate::str::contains("AI-powered Git assistant"))
.stdout(predicate::str::contains("Usage:"))
.stdout(predicate::str::contains("Commands:"))
.stdout(predicate::str::contains("Options:"));
}
#[test]
fn test_version() {
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.arg("--version");
cmd.assert()
.success()
.stdout(predicate::str::contains("quicommit"));
}
#[test]
fn test_no_args_shows_help() {
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.assert()
.failure()
.stderr(predicate::str::contains("Usage:"));
}
#[test]
fn test_verbose_flag() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
let config_path = repo_path.join("config.toml");
create_git_repo(&repo_path);
configure_git_user(&repo_path);
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"-vv",
"init",
"--yes",
"--config",
config_path.to_str().unwrap(),
])
.current_dir(&repo_path);
cmd.assert().success();
}
}
mod init_command {
use super::*;
#[test]
fn test_init_quick() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
cmd.assert()
.success()
.stdout(predicate::str::contains("initialized successfully"));
}
#[test]
fn test_init_creates_config_file() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
cmd.assert().success();
assert!(config_path.exists(), "Config file should be created");
}
#[test]
fn test_init_in_git_repo() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
create_git_repo(&repo_path);
configure_git_user(&repo_path);
let config_path = repo_path.join("test_config.toml");
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()])
.current_dir(&repo_path);
cmd.assert().success();
}
#[test]
fn test_init_reset() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
cmd.assert().success();
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"init",
"--yes",
"--reset",
"--config",
config_path.to_str().unwrap(),
]);
cmd.assert()
.success()
.stdout(predicate::str::contains("initialized successfully"));
}
}
mod profile_command {
use super::*;
#[test]
fn test_profile_list_empty() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&["profile", "list", "--config", config_path.to_str().unwrap()]);
cmd.assert()
.success()
.stdout(predicate::str::contains("No profiles"));
}
#[test]
fn test_profile_list_with_profile() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
cmd.assert().success();
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&["profile", "list", "--config", config_path.to_str().unwrap()]);
cmd.assert()
.success()
.stdout(predicate::str::contains("default"));
}
}
mod config_command {
use super::*;
#[test]
fn test_config_show() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
cmd.assert().success();
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&["config", "show", "--config", config_path.to_str().unwrap()]);
cmd.assert()
.success()
.stdout(predicate::str::contains("Configuration"));
}
#[test]
fn test_config_path() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
cmd.assert().success();
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&["config", "path", "--config", config_path.to_str().unwrap()]);
cmd.assert()
.success()
.stdout(predicate::str::contains("config.toml"));
}
#[test]
fn test_no_color_disables_ansi() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
cmd.assert().success();
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"config",
"show",
"--no-color",
"--config",
config_path.to_str().unwrap(),
]);
let output = cmd.output().unwrap();
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
!stdout.contains("\x1b["),
"ANSI color codes present despite --no-color"
);
}
#[test]
fn test_emoji_switch_precedence() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.env_remove("NO_COLOR");
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
cmd.assert().success();
let set_format = |extra: &[&str], cfg: &std::path::Path| {
let mut c = cargo_bin_cmd!("quicommit");
c.env_remove("NO_COLOR");
c.args(&["config", "set", "commit.format", "conventional"])
.args(extra)
.args(&["--config", cfg.to_str().unwrap()]);
c.output().unwrap()
};
// Default (config true): decorations shown
let out = set_format(&[], &config_path);
assert!(out.status.success());
assert!(String::from_utf8_lossy(&out.stdout).contains(""));
// Config output.emoji=false: no decorations
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.env_remove("NO_COLOR");
cmd.args(&[
"config",
"set",
"output.emoji",
"false",
"--config",
config_path.to_str().unwrap(),
]);
cmd.assert().success();
let out = set_format(&[], &config_path);
assert!(out.status.success());
assert!(!String::from_utf8_lossy(&out.stdout).contains(""));
// Explicit --emoji overrides config false
let out = set_format(&["--emoji"], &config_path);
assert!(out.status.success());
assert!(String::from_utf8_lossy(&out.stdout).contains(""));
// Config back to true, then --no-emoji overrides
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.env_remove("NO_COLOR");
cmd.args(&[
"config",
"set",
"output.emoji",
"true",
"--config",
config_path.to_str().unwrap(),
]);
cmd.assert().success();
let out = set_format(&["--no-emoji"], &config_path);
assert!(out.status.success());
assert!(!String::from_utf8_lossy(&out.stdout).contains(""));
}
#[test]
fn test_chinese_output_smoke() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
setup_test_repo_with_file(&repo_path, "test.txt", "content");
let config_path = repo_path.join("config.toml");
init_quicommit(&repo_path, &config_path);
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.env_remove("NO_COLOR");
cmd.args(&[
"config",
"set",
"language.output_language",
"zh",
"--config",
config_path.to_str().unwrap(),
]);
cmd.assert().success();
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.env_remove("NO_COLOR");
cmd.args(&[
"commit",
"--manual",
"-m",
"feat: 中文冒烟",
"--dry-run",
"--yes",
"--config",
config_path.to_str().unwrap(),
])
.current_dir(&repo_path);
cmd.assert()
.success()
.stdout(predicate::str::contains("试运行"));
}
#[test]
fn test_rust_log_env() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.env_remove("NO_COLOR");
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
cmd.assert().success();
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.env_remove("NO_COLOR");
cmd.env("RUST_LOG", "debug");
cmd.args(&["config", "path", "--config", config_path.to_str().unwrap()]);
cmd.assert()
.success()
.stdout(predicate::str::contains("Starting quicommit"));
}
#[test]
fn test_auto_generate_defaults_true() {
// Documents the unified --yes semantics (issue 05): with default
// config, --yes still AI-generates; deterministic output needs
// --no-generate (changelog) or -m (tag).
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.env_remove("NO_COLOR");
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
cmd.assert().success();
for key in [
"commit.auto_generate",
"tag.auto_generate",
"changelog.auto_generate",
] {
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.env_remove("NO_COLOR");
cmd.args(&[
"config",
"get",
key,
"--config",
config_path.to_str().unwrap(),
]);
cmd.assert()
.success()
.stdout(predicate::str::contains("true"));
}
}
}
mod commit_command {
use super::*;
#[test]
fn test_commit_no_repo() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
cmd.assert().success();
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"commit",
"--dry-run",
"--yes",
"--config",
config_path.to_str().unwrap(),
])
.current_dir(temp_dir.path());
cmd.assert()
.failure()
.stderr(predicate::str::contains("git").or(predicate::str::contains("repository")));
}
#[test]
fn test_commit_no_changes() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
setup_git_repo(&repo_path);
let config_path = repo_path.join("config.toml");
init_quicommit(&repo_path, &config_path);
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"commit",
"--manual",
"-m",
"test: empty",
"--dry-run",
"--yes",
"--config",
config_path.to_str().unwrap(),
])
.current_dir(&repo_path);
cmd.assert()
.success()
.stdout(predicate::str::contains("Dry run"));
}
#[test]
fn test_commit_with_staged_changes() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
setup_test_repo_with_file(&repo_path, "test.txt", "Hello, World!");
let config_path = repo_path.join("config.toml");
init_quicommit(&repo_path, &config_path);
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"commit",
"--manual",
"-m",
"test: add test file",
"--dry-run",
"--yes",
"--config",
config_path.to_str().unwrap(),
])
.current_dir(&repo_path);
cmd.assert()
.success()
.stdout(predicate::str::contains("Dry run"));
}
#[test]
fn test_commit_date_mode() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
setup_test_repo_with_file(&repo_path, "daily.txt", "Daily update");
let config_path = repo_path.join("config.toml");
init_quicommit(&repo_path, &config_path);
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"commit",
"--date",
"--dry-run",
"--yes",
"--config",
config_path.to_str().unwrap(),
])
.current_dir(&repo_path);
cmd.assert()
.success()
.stdout(predicate::str::contains("Dry run"));
}
#[test]
fn test_commit_with_think_flag() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
setup_test_repo_with_file(&repo_path, "test.txt", "Hello, World!");
let config_path = repo_path.join("config.toml");
init_quicommit(&repo_path, &config_path);
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"commit",
"--think",
"--manual",
"-m",
"test: think flag",
"--dry-run",
"--yes",
"--config",
config_path.to_str().unwrap(),
])
.current_dir(&repo_path);
cmd.assert().success();
}
}
mod tag_command {
use super::*;
#[test]
fn test_tag_no_repo() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
cmd.assert().success();
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"tag",
"--dry-run",
"--yes",
"--config",
config_path.to_str().unwrap(),
])
.current_dir(temp_dir.path());
cmd.assert()
.failure()
.stderr(predicate::str::contains("git").or(predicate::str::contains("repository")));
}
#[test]
fn test_tag_list_empty() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
setup_git_repo(&repo_path);
create_test_file(&repo_path, "test.txt", "content");
stage_file(&repo_path, "test.txt");
create_commit(&repo_path, "feat: initial commit");
let config_path = repo_path.join("config.toml");
init_quicommit(&repo_path, &config_path);
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"tag",
"--name",
"v0.1.0",
"-m",
"Release v0.1.0",
"--dry-run",
"--yes",
"--config",
config_path.to_str().unwrap(),
])
.current_dir(&repo_path);
cmd.assert()
.success()
.stdout(predicate::str::contains("v0.1.0"));
}
#[test]
fn test_tag_with_think_flag() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
setup_git_repo(&repo_path);
create_test_file(&repo_path, "test.txt", "content");
stage_file(&repo_path, "test.txt");
create_commit(&repo_path, "feat: initial commit");
let config_path = repo_path.join("config.toml");
init_quicommit(&repo_path, &config_path);
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"tag",
"--think",
"--name",
"v0.2.0",
"-m",
"Release v0.2.0",
"--dry-run",
"--yes",
"--config",
config_path.to_str().unwrap(),
])
.current_dir(&repo_path);
cmd.assert().success();
}
}
mod changelog_command {
use super::*;
#[test]
fn test_changelog_init() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
setup_git_repo(&repo_path);
let config_path = repo_path.join("config.toml");
let changelog_path = repo_path.join("CHANGELOG.md");
init_quicommit(&repo_path, &config_path);
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"changelog",
"--init",
"--output",
changelog_path.to_str().unwrap(),
"--config",
config_path.to_str().unwrap(),
])
.current_dir(&repo_path);
cmd.assert().success();
assert!(changelog_path.exists(), "Changelog file should be created");
}
#[test]
fn test_changelog_dry_run() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
setup_git_repo(&repo_path);
create_test_file(&repo_path, "test.txt", "content");
stage_file(&repo_path, "test.txt");
create_commit(&repo_path, "feat: add feature");
let config_path = repo_path.join("config.toml");
init_quicommit(&repo_path, &config_path);
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"changelog",
"--dry-run",
"--yes",
"--no-generate",
"--config",
config_path.to_str().unwrap(),
])
.current_dir(&repo_path);
cmd.assert().success();
}
}
mod cross_platform {
use super::*;
#[test]
fn test_path_handling_windows_style() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("subdir").join("config.toml");
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
cmd.assert().success();
assert!(config_path.exists());
}
#[test]
fn test_config_with_spaces_in_path() {
let temp_dir = TempDir::new().unwrap();
let space_dir = temp_dir.path().join("path with spaces");
fs::create_dir_all(&space_dir).unwrap();
let config_path = space_dir.join("config.toml");
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
cmd.assert().success();
assert!(config_path.exists());
}
#[test]
fn test_config_with_unicode_path() {
let temp_dir = TempDir::new().unwrap();
let unicode_dir = temp_dir.path().join("路径测试");
fs::create_dir_all(&unicode_dir).unwrap();
let config_path = unicode_dir.join("config.toml");
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
cmd.assert().success();
assert!(config_path.exists());
}
}
mod git_operations {
use super::*;
#[test]
fn test_git_repo_detection() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
create_git_repo(&repo_path);
configure_git_user(&repo_path);
let git_dir = repo_path.join(".git");
assert!(git_dir.exists(), ".git directory should exist");
}
#[test]
fn test_git_status_clean() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
create_git_repo(&repo_path);
configure_git_user(&repo_path);
let output = std::process::Command::new("git")
.args(&["status", "--porcelain"])
.current_dir(&repo_path)
.output()
.expect("Failed to run git status");
assert!(output.status.success());
assert!(String::from_utf8_lossy(&output.stdout).is_empty());
}
#[test]
fn test_git_commit_creation() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
create_git_repo(&repo_path);
configure_git_user(&repo_path);
create_test_file(&repo_path, "test.txt", "content");
stage_file(&repo_path, "test.txt");
create_commit(&repo_path, "feat: initial commit");
let output = std::process::Command::new("git")
.args(&["log", "--oneline"])
.current_dir(&repo_path)
.output()
.expect("Failed to run git log");
assert!(output.status.success());
let log = String::from_utf8_lossy(&output.stdout);
assert!(log.contains("initial commit"));
}
}
mod validators {
use super::*;
#[test]
fn test_commit_message_validation() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
setup_test_repo_with_file(&repo_path, "test.txt", "content");
let config_path = repo_path.join("config.toml");
init_quicommit(&repo_path, &config_path);
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"commit",
"--manual",
"-m",
"invalid commit message without type",
"--dry-run",
"--yes",
"--config",
config_path.to_str().unwrap(),
])
.current_dir(&repo_path);
cmd.assert()
.failure()
.stderr(predicate::str::contains("Invalid").or(predicate::str::contains("format")));
}
#[test]
fn test_valid_conventional_commit() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
setup_test_repo_with_file(&repo_path, "test.txt", "content");
let config_path = repo_path.join("config.toml");
init_quicommit(&repo_path, &config_path);
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"commit",
"--manual",
"-m",
"feat: add new feature",
"--dry-run",
"--yes",
"--config",
config_path.to_str().unwrap(),
])
.current_dir(&repo_path);
cmd.assert()
.success()
.stdout(predicate::str::contains("Dry run"));
}
}
mod subcommands {
use super::*;
#[test]
fn test_commit_alias() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
setup_test_repo_with_file(&repo_path, "test.txt", "content");
let config_path = repo_path.join("config.toml");
init_quicommit(&repo_path, &config_path);
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"c",
"--manual",
"-m",
"fix: test",
"--dry-run",
"--yes",
"--config",
config_path.to_str().unwrap(),
])
.current_dir(&repo_path);
cmd.assert()
.success()
.stdout(predicate::str::contains("Dry run"));
}
#[test]
fn test_init_alias() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&["i", "--yes", "--config", config_path.to_str().unwrap()]);
cmd.assert()
.success()
.stdout(predicate::str::contains("initialized successfully"));
}
#[test]
fn test_profile_alias() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
cmd.assert().success();
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&["p", "list", "--config", config_path.to_str().unwrap()]);
cmd.assert()
.success()
.stdout(predicate::str::contains("default"));
}
}
mod edge_cases {
use super::*;
#[test]
fn test_config_file_not_found() {
let temp_dir = TempDir::new().unwrap();
let non_existent_config = temp_dir.path().join("non_existent_config.toml");
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"config",
"show",
"--config",
non_existent_config.to_str().unwrap(),
]);
cmd.assert()
.success()
.stdout(predicate::str::contains("QuiCommit Configuration"))
.stdout(predicate::str::contains("Default profile: (none)"))
.stdout(predicate::str::contains("Profiles: 0"));
}
#[test]
fn test_invalid_git_repo() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
let config_path = repo_path.join("config.toml");
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&["init", "--yes", "--config", config_path.to_str().unwrap()]);
cmd.assert().success();
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"commit",
"--dry-run",
"--yes",
"--config",
config_path.to_str().unwrap(),
])
.current_dir(&repo_path);
cmd.assert()
.failure()
.stderr(predicate::str::contains("git").or(predicate::str::contains("repository")));
}
#[test]
fn test_empty_commit_message() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
setup_test_repo_with_file(&repo_path, "test.txt", "content");
let config_path = repo_path.join("config.toml");
init_quicommit(&repo_path, &config_path);
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"commit",
"--manual",
"-m",
"",
"--dry-run",
"--yes",
"--config",
config_path.to_str().unwrap(),
])
.current_dir(&repo_path);
cmd.assert().failure().stderr(predicate::str::contains(
"Invalid conventional commit format",
));
}
}
fn create_bare_remote(path: &std::path::Path) {
std::fs::create_dir(path).expect("Failed to create remote directory");
std::process::Command::new("git")
.args(["init", "--bare"])
.current_dir(path)
.output()
.expect("Failed to init bare remote");
}
fn add_remote(dir: &PathBuf, name: &str, url: &str) {
std::process::Command::new("git")
.args(["remote", "add", name, url])
.current_dir(dir)
.output()
.expect("Failed to add remote");
}
fn bare_head_commit(bare_path: &std::path::Path) -> Option<String> {
let output = std::process::Command::new("git")
.arg("--git-dir")
.arg(bare_path)
.args(["rev-parse", "HEAD"])
.output()
.expect("Failed to read bare repo HEAD");
if !output.status.success() {
return None;
}
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn bare_tag_exists(bare_path: &std::path::Path, tag: &str) -> bool {
std::process::Command::new("git")
.arg("--git-dir")
.arg(bare_path)
.args(["rev-parse", &format!("refs/tags/{}", tag)])
.output()
.expect("Failed to read remote tag")
.status
.success()
}
fn repo_head_commit(dir: &PathBuf) -> String {
let output = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(dir)
.output()
.expect("Failed to read repo HEAD");
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
mod remote_aware_push {
use super::*;
#[test]
fn test_commit_no_remote_skips_push_prompt() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
setup_test_repo_with_file(&repo_path, "test.txt", "Hello, World!");
let config_path = repo_path.join("config.toml");
init_quicommit(&repo_path, &config_path);
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"commit",
"--manual",
"-m",
"test: no remote",
"--yes",
"--config",
config_path.to_str().unwrap(),
])
.current_dir(&repo_path);
cmd.assert()
.success()
.stdout(predicate::str::contains("No remote configured, skipping push."));
}
#[test]
fn test_commit_no_remote_with_push_flag_skips_instead_of_failing() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
setup_test_repo_with_file(&repo_path, "test.txt", "Hello, World!");
let config_path = repo_path.join("config.toml");
init_quicommit(&repo_path, &config_path);
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"commit",
"--manual",
"-m",
"test: no remote with push",
"--yes",
"--push",
"--config",
config_path.to_str().unwrap(),
])
.current_dir(&repo_path);
cmd.assert()
.success()
.stdout(predicate::str::contains("No remote configured, skipping push."))
.stdout(predicate::str::contains("Push failed").not());
}
#[test]
fn test_commit_with_remote_still_pushes_to_origin() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
let remote_path = temp_dir.path().join("remote.git");
create_bare_remote(&remote_path);
setup_test_repo_with_file(&repo_path, "test.txt", "Hello, World!");
add_remote(&repo_path, "origin", remote_path.to_str().unwrap());
let config_path = repo_path.join("config.toml");
init_quicommit(&repo_path, &config_path);
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"commit",
"--manual",
"-m",
"test: push to origin",
"--yes",
"--push",
"--config",
config_path.to_str().unwrap(),
])
.current_dir(&repo_path);
cmd.assert()
.success()
.stdout(predicate::str::contains("Pushed branch"));
assert_eq!(
bare_head_commit(&remote_path),
Some(repo_head_commit(&repo_path)),
"bare remote should contain the pushed commit"
);
}
#[test]
fn test_tag_no_remote_skips_push_prompt() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
setup_git_repo(&repo_path);
create_test_file(&repo_path, "test.txt", "content");
stage_file(&repo_path, "test.txt");
create_commit(&repo_path, "feat: initial commit");
let config_path = repo_path.join("config.toml");
init_quicommit(&repo_path, &config_path);
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"tag",
"--name",
"v0.1.0",
"-m",
"Release v0.1.0",
"--yes",
"--config",
config_path.to_str().unwrap(),
])
.current_dir(&repo_path);
cmd.assert()
.success()
.stdout(predicate::str::contains("No remote configured, skipping push."));
}
#[test]
fn test_tag_with_remote_still_pushes_to_origin() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
let remote_path = temp_dir.path().join("remote.git");
create_bare_remote(&remote_path);
setup_git_repo(&repo_path);
create_test_file(&repo_path, "test.txt", "content");
stage_file(&repo_path, "test.txt");
create_commit(&repo_path, "feat: initial commit");
add_remote(&repo_path, "origin", remote_path.to_str().unwrap());
let config_path = repo_path.join("config.toml");
init_quicommit(&repo_path, &config_path);
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"tag",
"--name",
"v0.1.0",
"-m",
"Release v0.1.0",
"--yes",
"--push",
"--config",
config_path.to_str().unwrap(),
])
.current_dir(&repo_path);
cmd.assert()
.success()
.stdout(predicate::str::contains("Pushed tag"));
assert!(
bare_tag_exists(&remote_path, "v0.1.0"),
"remote should contain the pushed tag"
);
}
#[test]
fn test_commit_falls_back_to_first_available_remote() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
let remote_path = temp_dir.path().join("upstream.git");
create_bare_remote(&remote_path);
setup_test_repo_with_file(&repo_path, "test.txt", "Hello, World!");
add_remote(&repo_path, "upstream", remote_path.to_str().unwrap());
let config_path = repo_path.join("config.toml");
init_quicommit(&repo_path, &config_path);
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"commit",
"--manual",
"-m",
"test: fallback push",
"--yes",
"--push",
"--config",
config_path.to_str().unwrap(),
])
.current_dir(&repo_path);
cmd.assert()
.success()
.stdout(predicate::str::contains(
"Remote 'origin' not found, pushing to 'upstream' instead.",
))
.stdout(predicate::str::contains("Pushed branch"));
assert_eq!(
bare_head_commit(&remote_path),
Some(repo_head_commit(&repo_path)),
"fallback remote should contain the pushed commit"
);
}
#[test]
fn test_commit_falls_back_when_custom_remote_missing() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
let remote_path = temp_dir.path().join("origin.git");
create_bare_remote(&remote_path);
setup_test_repo_with_file(&repo_path, "test.txt", "Hello, World!");
add_remote(&repo_path, "origin", remote_path.to_str().unwrap());
let config_path = repo_path.join("config.toml");
init_quicommit(&repo_path, &config_path);
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"commit",
"--manual",
"-m",
"test: custom remote missing",
"--yes",
"--push",
"--remote",
"nonexistent",
"--config",
config_path.to_str().unwrap(),
])
.current_dir(&repo_path);
cmd.assert()
.success()
.stdout(predicate::str::contains(
"Remote 'nonexistent' not found, pushing to 'origin' instead.",
))
.stdout(predicate::str::contains("Pushed branch"));
}
#[test]
fn test_tag_falls_back_to_first_available_remote() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
let remote_path = temp_dir.path().join("upstream.git");
create_bare_remote(&remote_path);
setup_git_repo(&repo_path);
create_test_file(&repo_path, "test.txt", "content");
stage_file(&repo_path, "test.txt");
create_commit(&repo_path, "feat: initial commit");
add_remote(&repo_path, "upstream", remote_path.to_str().unwrap());
let config_path = repo_path.join("config.toml");
init_quicommit(&repo_path, &config_path);
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"tag",
"--name",
"v0.1.0",
"-m",
"Release v0.1.0",
"--yes",
"--push",
"--config",
config_path.to_str().unwrap(),
])
.current_dir(&repo_path);
cmd.assert()
.success()
.stdout(predicate::str::contains(
"Remote 'origin' not found, pushing to 'upstream' instead.",
))
.stdout(predicate::str::contains("Pushed tag"));
assert!(
bare_tag_exists(&remote_path, "v0.1.0"),
"fallback remote should contain the pushed tag"
);
}
#[test]
fn test_commit_select_remote_yes_pushes_only_remote_target() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
let origin_path = temp_dir.path().join("origin.git");
let upstream_path = temp_dir.path().join("upstream.git");
create_bare_remote(&origin_path);
create_bare_remote(&upstream_path);
setup_test_repo_with_file(&repo_path, "test.txt", "Hello, World!");
add_remote(&repo_path, "origin", origin_path.to_str().unwrap());
add_remote(&repo_path, "upstream", upstream_path.to_str().unwrap());
let config_path = repo_path.join("config.toml");
init_quicommit(&repo_path, &config_path);
// --yes outranks --select-remote: no interaction, push only --remote target
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"commit",
"--manual",
"-m",
"test: select remote with yes",
"--yes",
"--push",
"--select-remote",
"--config",
config_path.to_str().unwrap(),
])
.current_dir(&repo_path);
cmd.assert().success();
assert_eq!(
bare_head_commit(&origin_path),
Some(repo_head_commit(&repo_path)),
"origin should receive the push"
);
assert_eq!(
bare_head_commit(&upstream_path),
None,
"upstream should not receive the push under --yes"
);
}
#[test]
fn test_commit_select_remote_no_remote_skips_push() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
setup_test_repo_with_file(&repo_path, "test.txt", "Hello, World!");
let config_path = repo_path.join("config.toml");
init_quicommit(&repo_path, &config_path);
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"commit",
"--manual",
"-m",
"test: select remote without remotes",
"--yes",
"--select-remote",
"--config",
config_path.to_str().unwrap(),
])
.current_dir(&repo_path);
cmd.assert()
.success()
.stdout(predicate::str::contains("No remote configured, skipping push."));
}
#[test]
fn test_commit_select_remote_single_remote_with_yes_pushes_directly() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
let origin_path = temp_dir.path().join("origin.git");
create_bare_remote(&origin_path);
setup_test_repo_with_file(&repo_path, "test.txt", "Hello, World!");
add_remote(&repo_path, "origin", origin_path.to_str().unwrap());
let config_path = repo_path.join("config.toml");
init_quicommit(&repo_path, &config_path);
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"commit",
"--manual",
"-m",
"test: single remote select",
"--yes",
"--push",
"--select-remote",
"--config",
config_path.to_str().unwrap(),
])
.current_dir(&repo_path);
cmd.assert()
.success()
.stdout(predicate::str::contains("Pushed branch"));
assert_eq!(
bare_head_commit(&origin_path),
Some(repo_head_commit(&repo_path))
);
}
#[test]
fn test_commit_push_failure_reports_error() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
// Remote points to a path that is not a git repository
let broken_path = temp_dir.path().join("not-a-repo");
std::fs::create_dir(&broken_path).unwrap();
setup_test_repo_with_file(&repo_path, "test.txt", "Hello, World!");
add_remote(&repo_path, "origin", broken_path.to_str().unwrap());
let config_path = repo_path.join("config.toml");
init_quicommit(&repo_path, &config_path);
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"commit",
"--manual",
"-m",
"test: push failure",
"--yes",
"--push",
"--config",
config_path.to_str().unwrap(),
])
.current_dir(&repo_path);
cmd.assert()
.failure()
.stderr(predicate::str::contains("Push failed"));
}
#[test]
fn test_tag_select_remote_yes_pushes_only_remote_target() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
let origin_path = temp_dir.path().join("origin.git");
let upstream_path = temp_dir.path().join("upstream.git");
create_bare_remote(&origin_path);
create_bare_remote(&upstream_path);
setup_git_repo(&repo_path);
create_test_file(&repo_path, "test.txt", "content");
stage_file(&repo_path, "test.txt");
create_commit(&repo_path, "feat: initial commit");
add_remote(&repo_path, "origin", origin_path.to_str().unwrap());
add_remote(&repo_path, "upstream", upstream_path.to_str().unwrap());
let config_path = repo_path.join("config.toml");
init_quicommit(&repo_path, &config_path);
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"tag",
"--name",
"v0.1.0",
"-m",
"Release v0.1.0",
"--yes",
"--push",
"--select-remote",
"--config",
config_path.to_str().unwrap(),
])
.current_dir(&repo_path);
cmd.assert().success();
assert!(
bare_tag_exists(&origin_path, "v0.1.0"),
"origin should have the tag"
);
assert!(
!bare_tag_exists(&upstream_path, "v0.1.0"),
"upstream should not receive the tag under --yes"
);
}
#[test]
fn test_tag_no_remote_with_push_flag_skips_instead_of_failing() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
setup_git_repo(&repo_path);
create_test_file(&repo_path, "test.txt", "content");
stage_file(&repo_path, "test.txt");
create_commit(&repo_path, "feat: initial commit");
let config_path = repo_path.join("config.toml");
init_quicommit(&repo_path, &config_path);
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"tag",
"--name",
"v0.1.0",
"-m",
"Release v0.1.0",
"--yes",
"--push",
"--config",
config_path.to_str().unwrap(),
])
.current_dir(&repo_path);
cmd.assert()
.success()
.stdout(predicate::str::contains("No remote configured, skipping push."));
}
#[test]
fn test_tag_push_failure_reports_error() {
let temp_dir = TempDir::new().unwrap();
let repo_path = temp_dir.path().to_path_buf();
// Remote points to a path that is not a git repository
let broken_path = temp_dir.path().join("not-a-repo");
std::fs::create_dir(&broken_path).unwrap();
setup_git_repo(&repo_path);
create_test_file(&repo_path, "test.txt", "content");
stage_file(&repo_path, "test.txt");
create_commit(&repo_path, "feat: initial commit");
add_remote(&repo_path, "origin", broken_path.to_str().unwrap());
let config_path = repo_path.join("config.toml");
init_quicommit(&repo_path, &config_path);
let mut cmd = cargo_bin_cmd!("quicommit");
cmd.args(&[
"tag",
"--name",
"v0.1.0",
"-m",
"Release v0.1.0",
"--yes",
"--push",
"--config",
config_path.to_str().unwrap(),
])
.current_dir(&repo_path);
cmd.assert()
.failure()
.stderr(predicate::str::contains("Push failed"));
}
}