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

@@ -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 {

View File

@@ -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<String> = 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(())
}

View File

@@ -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(

View File

@@ -971,6 +971,29 @@ impl GitRepo {
Ok(url.to_string())
}
/// List all configured remote names, sorted alphabetically
pub fn list_remotes(&self) -> Result<Vec<String>> {
let names = self.repo.remotes()?;
let mut remotes: Vec<String> = 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<Option<(String, bool)>> {
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<bool> {
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))
);
}
}

View File

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