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:
2026-08-31 14:19:58 +08:00
parent b391bdabaf
commit fac96021fb
8 changed files with 939 additions and 41 deletions

View File

@@ -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"));
}
}