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 种语言
This commit is contained in:
@@ -1018,3 +1018,581 @@ mod edge_cases {
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user