diff --git a/README.md b/README.md index 2328d30..4632cb4 100644 --- a/README.md +++ b/README.md @@ -336,6 +336,7 @@ quicommit config reset --force | `-y, --yes` | Skip confirmation prompts only (generation behavior unchanged) | | `--push` | Push after committing | | `--remote` | Specify remote repository (default: origin) | +| `--select-remote` | Interactively select push target(s) when the repo has multiple remotes | ### Tag Options @@ -351,6 +352,7 @@ quicommit config reset --force | `-f, --force` | Force overwrite existing tag | | `-p, --push` | Push to remote | | `-r, --remote` | Specify remote repository (default: origin) | +| `--select-remote` | Interactively select push target(s) when the repo has multiple remotes | | `--dry-run` | Dry run | | `-t, --think` | Enable LLM thinking/reasoning mode (overrides config) | | `-y, --yes` | Skip confirmation prompts only (generation behavior unchanged) | diff --git a/readme_zh.md b/readme_zh.md index 92fb2dd..2a2d344 100644 --- a/readme_zh.md +++ b/readme_zh.md @@ -330,6 +330,7 @@ quicommit config reset --force | `-y, --yes` | 仅跳过确认提示(生成行为不变) | | `--push` | 提交后推送到远程 | | `--remote` | 指定远程仓库(默认:origin) | +| `--select-remote` | 多远程仓库时交互式选择推送目标(可多选) | ### tag命令选项 @@ -345,6 +346,7 @@ quicommit config reset --force | `-f, --force` | 强制覆盖已存在的标签 | | `-p, --push` | 推送到远程 | | `-r, --remote` | 指定远程仓库(默认:origin) | +| `--select-remote` | 多远程仓库时交互式选择推送目标(可多选) | | `--dry-run` | 试运行 | | `-t, --think` | 启用 LLM 思考/推理模式(覆盖配置) | | `-y, --yes` | 仅跳过确认提示(生成行为不变) | diff --git a/src/commands/commit.rs b/src/commands/commit.rs index ba8397b..76d1777 100644 --- a/src/commands/commit.rs +++ b/src/commands/commit.rs @@ -11,7 +11,7 @@ use crate::git::commit::{CommitBuilder, create_date_commit_message}; use crate::git::{GitRepo, find_repo}; use crate::i18n::Messages; use crate::utils::validators::get_commit_types; -use crate::utils::{print_progress, print_success, print_warning}; +use crate::utils::{print_success, print_warning}; /// Generate and execute conventional commits #[derive(Parser)] @@ -87,6 +87,10 @@ pub struct CommitCommand { /// Remote to push to #[arg(long, default_value = "origin")] remote: String, + + /// Interactively select push target(s) when the repo has multiple remotes + #[arg(long)] + select_remote: bool, } impl CommitCommand { @@ -229,28 +233,22 @@ impl CommitCommand { } // Push after commit if requested or ask user - if self.push || (!self.yes && !self.dry_run) { - let branch = repo - .current_branch() - .unwrap_or_else(|_| "HEAD (detached)".to_string()); + let branch = repo + .current_branch() + .unwrap_or_else(|_| "HEAD (detached)".to_string()); - let should_push = if self.push { - true - } else { - Confirm::new() - .with_prompt(messages.push_after_commit(&branch)) - .default(false) - .interact()? - }; - - if should_push { - print_progress(&messages.pushing_commit(&self.remote, &branch)); - repo.push(&self.remote, "HEAD")?; - print_success(&messages.pushed_commit(&self.remote, &branch)); - } - } - - Ok(()) + super::run_push_flow( + &repo, + &self.remote, + self.select_remote, + self.push, + self.yes, + &messages, + &messages.push_after_commit(&branch), + "HEAD", + |r| messages.pushing_commit(r, &branch), + |r| messages.pushed_commit(r, &branch), + ) } fn create_date_commit(&self) -> String { diff --git a/src/commands/mod.rs b/src/commands/mod.rs index f36a54b..62935a4 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -5,3 +5,101 @@ pub mod credential; pub mod init; pub mod profile; pub mod tag; + +use anyhow::{Result, bail}; +use dialoguer::{Confirm, MultiSelect}; + +use crate::git::GitRepo; +use crate::i18n::Messages; +use crate::utils::{print_progress, print_success, print_warning}; + +/// Shared push flow for commit and tag: resolve the push target, honor the +/// `--yes` > `--select-remote` > `--push` priority chain, then push to the +/// chosen target(s). `progress_msg`/`success_msg` render the per-remote +/// status lines; `refspec` is "HEAD" for commit, the tag ref for tag. +pub(crate) fn run_push_flow( + repo: &GitRepo, + requested_remote: &str, + select_remote: bool, + push: bool, + yes: bool, + messages: &Messages, + confirm_prompt: &str, + refspec: &str, + progress_msg: impl Fn(&str) -> String, + success_msg: impl Fn(&str) -> String, +) -> Result<()> { + let Some((target, fell_back)) = repo.resolve_push_target(requested_remote)? else { + print_warning(&messages.push_skipped_no_remote()); + return Ok(()); + }; + if fell_back && (push || !yes) { + print_warning(&messages.push_target_fallback(requested_remote, &target)); + } + + let remotes = if select_remote && !yes { + Some(repo.list_remotes()?) + } else { + None + }; + let use_multi_select = remotes.as_ref().is_some_and(|r| r.len() > 1); + + let targets: Vec = if use_multi_select { + let remotes = remotes.unwrap(); + let items: Vec<(&String, bool)> = remotes.iter().map(|r| (r, *r == target)).collect(); + let selected = MultiSelect::new() + .with_prompt(messages.select_push_remotes()) + .items_checked(&items) + .interact()?; + if selected.is_empty() { + print_warning(&messages.no_remotes_selected()); + return Ok(()); + } + selected.iter().map(|&i| remotes[i].clone()).collect() + } else if push { + vec![target] + } else if !yes { + if Confirm::new() + .with_prompt(confirm_prompt) + .default(false) + .interact()? + { + vec![target] + } else { + Vec::new() + } + } else { + Vec::new() + }; + + if targets.len() == 1 { + print_progress(&progress_msg(&targets[0])); + repo.push(&targets[0], refspec)?; + print_success(&success_msg(&targets[0])); + } else if targets.len() > 1 { + let fmt_list = |items: &[String]| { + if items.is_empty() { + "-".to_string() + } else { + items.join(", ") + } + }; + for (i, remote_name) in targets.iter().enumerate() { + print_progress(&progress_msg(remote_name)); + if let Err(e) = repo.push(remote_name, refspec) { + bail!( + "{}\n{}", + messages.push_multi_failed( + remote_name, + &fmt_list(&targets[..i]), + &fmt_list(&targets[i + 1..]) + ), + e + ); + } + print_success(&success_msg(remote_name)); + } + } + + Ok(()) +} diff --git a/src/commands/tag.rs b/src/commands/tag.rs index 7e20630..14bf90e 100644 --- a/src/commands/tag.rs +++ b/src/commands/tag.rs @@ -13,7 +13,7 @@ use crate::git::tag::{ }; use crate::git::{GitRepo, find_repo}; use crate::i18n::Messages; -use crate::utils::{print_progress, print_success, print_warning}; +use crate::utils::{print_success, print_warning}; /// Generate and create Git tags #[derive(Parser)] @@ -54,6 +54,10 @@ pub struct TagCommand { #[arg(short, long, default_value = "origin")] remote: String, + /// Interactively select push target(s) when the repo has multiple remotes + #[arg(long)] + select_remote: bool, + /// Dry run #[arg(long)] dry_run: bool, @@ -188,24 +192,18 @@ impl TagCommand { print_success(&format!("{} {}", messages.tag_created(), tag_name.cyan())); // Push if requested or ask user - if self.push { - print_progress(&messages.pushing_tag(&self.remote)); - repo.push(&self.remote, &format!("refs/tags/{}", tag_name))?; - print_success(&messages.pushed_tag(&self.remote)); - } else if !self.yes && !self.dry_run { - let should_push = Confirm::new() - .with_prompt(messages.push_after_tag()) - .default(false) - .interact()?; - - if should_push { - print_progress(&messages.pushing_tag(&self.remote)); - repo.push(&self.remote, &format!("refs/tags/{}", tag_name))?; - print_success(&messages.pushed_tag(&self.remote)); - } - } - - Ok(()) + super::run_push_flow( + &repo, + &self.remote, + self.select_remote, + self.push, + self.yes, + &messages, + messages.push_after_tag(), + &format!("refs/tags/{}", tag_name), + |r| messages.pushing_tag(r), + |r| messages.pushed_tag(r), + ) } async fn select_version_interactive( diff --git a/src/git/mod.rs b/src/git/mod.rs index 36ccfbe..38966f3 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -971,6 +971,29 @@ impl GitRepo { Ok(url.to_string()) } + /// List all configured remote names, sorted alphabetically + pub fn list_remotes(&self) -> Result> { + let names = self.repo.remotes()?; + let mut remotes: Vec = names.iter().flatten().map(|s| s.to_string()).collect(); + remotes.sort(); + Ok(remotes) + } + + /// Resolve the push target for a requested remote name. + /// Returns `None` when the repository has no remotes configured. + /// The flag marks that the requested remote was missing and the first + /// available remote (alphabetically) was used instead. + pub fn resolve_push_target(&self, requested: &str) -> Result> { + let remotes = self.list_remotes()?; + if remotes.iter().any(|r| r == requested) { + return Ok(Some((requested.to_string(), false))); + } + match remotes.first() { + Some(first) => Ok(Some((first.clone(), true))), + None => Ok(None), + } + } + /// Check if working directory is clean pub fn is_clean(&self) -> Result { Ok(!self.has_changes()?) @@ -1587,4 +1610,79 @@ mod tests { "tag.target should be the commit OID, not the tag object OID" ); } + + #[test] + fn test_list_remotes_empty_when_no_remote_configured() { + let (_dir, repo) = init_test_repo(); + + let remotes = repo.list_remotes().unwrap(); + assert!(remotes.is_empty(), "fresh repo should have no remotes"); + } + + #[test] + fn test_list_remotes_returns_all_names_sorted() { + let (_dir, repo) = init_test_repo(); + repo.repo + .remote("upstream", "https://example.com/upstream.git") + .unwrap(); + repo.repo + .remote("origin", "https://example.com/origin.git") + .unwrap(); + + let remotes = repo.list_remotes().unwrap(); + assert_eq!(remotes, vec!["origin".to_string(), "upstream".to_string()]); + } + + #[test] + fn test_resolve_push_target_none_when_no_remotes() { + let (_dir, repo) = init_test_repo(); + + assert_eq!(repo.resolve_push_target("origin").unwrap(), None); + } + + #[test] + fn test_resolve_push_target_returns_requested_when_it_exists() { + let (_dir, repo) = init_test_repo(); + repo.repo + .remote("upstream", "https://example.com/upstream.git") + .unwrap(); + repo.repo + .remote("origin", "https://example.com/origin.git") + .unwrap(); + + assert_eq!( + repo.resolve_push_target("origin").unwrap(), + Some(("origin".to_string(), false)) + ); + } + + #[test] + fn test_resolve_push_target_falls_back_to_first_available() { + let (_dir, repo) = init_test_repo(); + repo.repo + .remote("upstream", "https://example.com/upstream.git") + .unwrap(); + repo.repo + .remote("bitbucket", "https://example.com/bitbucket.git") + .unwrap(); + + // "origin" is missing; alphabetically first available remote wins + assert_eq!( + repo.resolve_push_target("origin").unwrap(), + Some(("bitbucket".to_string(), true)) + ); + } + + #[test] + fn test_resolve_push_target_single_non_requested_remote_falls_back() { + let (_dir, repo) = init_test_repo(); + repo.repo + .remote("upstream", "https://example.com/upstream.git") + .unwrap(); + + assert_eq!( + repo.resolve_push_target("origin").unwrap(), + Some(("upstream".to_string(), true)) + ); + } } diff --git a/src/i18n/messages.rs b/src/i18n/messages.rs index abf5bf7..9da0bc9 100644 --- a/src/i18n/messages.rs +++ b/src/i18n/messages.rs @@ -413,6 +413,114 @@ impl Messages { } } + pub fn push_skipped_no_remote(&self) -> &str { + match self.language { + Language::English => "No remote configured, skipping push.", + Language::Chinese => "未检测到远程仓库,跳过推送。", + Language::Japanese => "リモートが設定されていないため、プッシュをスキップします。", + Language::Korean => "원격이 구성되지 않아 푸시를 건너뜁니다.", + Language::Spanish => "No hay remoto configurado, se omite el envío.", + Language::French => "Aucun distant configuré, envoi ignoré.", + Language::German => "Kein Remote konfiguriert, Push wird übersprungen.", + } + } + + pub fn push_target_fallback(&self, requested: &str, actual: &str) -> String { + match self.language { + Language::English => format!( + "Remote '{}' not found, pushing to '{}' instead.", + requested, actual + ), + Language::Chinese => format!("未找到远程 '{}',将推送到 '{}'。", requested, actual), + Language::Japanese => { + format!("リモート '{}' が見つからないため、'{}' にプッシュします。", requested, actual) + } + Language::Korean => format!( + "'{}' 원격을 찾을 수 없어 '{}'로 푸시합니다.", + requested, actual + ), + Language::Spanish => format!( + "Remoto '{}' no encontrado, se enviará a '{}' en su lugar.", + requested, actual + ), + Language::French => format!( + "Distant '{}' introuvable, envoi vers '{}' à la place.", + requested, actual + ), + Language::German => format!( + "Remote '{}' nicht gefunden, stattdessen Push zu '{}'.", + requested, actual + ), + } + } + + pub fn select_push_remotes(&self) -> &str { + match self.language { + Language::English => { + "Select remote(s) to push to (space to toggle, enter to confirm):" + } + Language::Chinese => "选择要推送的远程仓库(空格勾选,回车确认):", + Language::Japanese => { + "プッシュ先のリモートを選択してください(スペースで選択、Enterで確定):" + } + Language::Korean => "푸시할 원격을 선택하세요(스페이스로 선택, Enter로 확정):", + Language::Spanish => { + "Selecciona los remotos donde enviar (espacio para marcar, enter para confirmar):" + } + Language::French => { + "Sélectionnez les distants où envoyer (espace pour cocher, entrée pour valider) :" + } + Language::German => { + "Zu pushende Remotes auswählen (Leertaste zum Markieren, Enter zum Bestätigen):" + } + } + } + + pub fn no_remotes_selected(&self) -> &str { + match self.language { + Language::English => "No remote selected, skipping push.", + Language::Chinese => "未选择任何远程,跳过推送。", + Language::Japanese => "リモートが選択されていないため、プッシュをスキップします。", + Language::Korean => "선택된 원격이 없어 푸시를 건너뜁니다.", + Language::Spanish => "No se seleccionó ningún remoto, se omite el envío.", + Language::French => "Aucun distant sélectionné, envoi ignoré.", + Language::German => "Kein Remote ausgewählt, Push wird übersprungen.", + } + } + + pub fn push_multi_failed(&self, failed: &str, pushed: &str, pending: &str) -> String { + match self.language { + Language::English => format!( + "Push to '{}' failed. Pushed: {}. Not attempted: {}.", + failed, pushed, pending + ), + Language::Chinese => format!( + "推送到 '{}' 失败。已推送:{}。未尝试:{}。", + failed, pushed, pending + ), + Language::Japanese => format!( + "'{}' へのプッシュに失敗しました。プッシュ済み: {}。未実行: {}。", + failed, pushed, pending + ), + Language::Korean => format!( + "'{}' 푸시에 실패했습니다. 푸시됨: {}. 시도하지 않음: {}.", + failed, pushed, pending + ), + Language::Spanish => format!( + "Error al enviar a '{}'. Enviados: {}. Sin intentar: {}.", + failed, pushed, pending + ), + Language::French => format!( + "Échec de l'envoi vers '{}'. Envoyés : {}. Non tentés : {}.", + failed, pushed, pending + ), + Language::German => format!( + "Push zu '{}' fehlgeschlagen. Gepusht: {}. Nicht versucht: {}.", + failed, pushed, pending + ), + } + } + pub fn ai_analyzing(&self) -> &str { match self.language { Language::English => "AI is analyzing your changes...", @@ -4875,3 +4983,19 @@ impl Messages { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn push_multi_failed_lists_failed_pushed_and_pending() { + let messages = Messages::new(Language::English); + + let msg = messages.push_multi_failed("origin", "upstream, gitlab", "-"); + + assert!(msg.contains("Push to 'origin' failed."), "got: {}", msg); + assert!(msg.contains("Pushed: upstream, gitlab."), "got: {}", msg); + assert!(msg.contains("Not attempted: -."), "got: {}", msg); + } +} diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index da55e10..8dcd3f2 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -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 { + 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")); + } +}