Compare commits
3 Commits
b391bdabaf
...
1dd98885da
| Author | SHA1 | Date | |
|---|---|---|---|
|
1dd98885da
|
|||
|
8ff7504d96
|
|||
|
fac96021fb
|
17
CHANGELOG.md
17
CHANGELOG.md
@@ -9,6 +9,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
暂无。
|
||||
|
||||
## [0.8.1] - 2026-08-31
|
||||
|
||||
### ✨ 新功能
|
||||
- `commit` 与 `tag` 命令新增 `--select-remote` 标志:仓库存在多个远程时,以多选形式交互式选择推送目标(可多选),与 `--push`、`--yes` 形成优先链(`--yes` > `--select-remote` > `--push`)
|
||||
- 推送流程重构为共享的 `run_push_flow`,感知远程配置:请求的远程不存在时回退到按字母序首个可用远程并提示;多远程推送失败时输出已推送/未尝试清单
|
||||
|
||||
### 🐞 错误修复
|
||||
- 修复未指定 `--no-color` 时对 `colored` 的强制颜色覆盖会破坏 TTY 自动检测的问题:现仅在明确禁用时才强制关闭颜色,管道输出保持无 ANSI 转义
|
||||
|
||||
### 📚 文档
|
||||
- README(中/英文)同步补充 `--select-remote` 选项说明
|
||||
|
||||
### 🔧 其他变更
|
||||
- 新增 `GitRepo::list_remotes()` 与 `GitRepo::resolve_push_target()` 辅助方法及单元测试
|
||||
- 新增多语言推送消息(`push_skipped_no_remote`、`push_target_fallback`、`select_push_remotes`、`no_remotes_selected`、`push_multi_failed`),覆盖 7 种语言
|
||||
- 新增 `tests/integration_tests.rs` 集成测试,覆盖远程解析与多远程推送流程
|
||||
|
||||
## [0.7.0] - 2026-08-21
|
||||
|
||||
### ✨ 新功能
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "quicommit"
|
||||
version = "0.7.0"
|
||||
version = "0.8.1"
|
||||
edition = "2024"
|
||||
authors = ["Sidney Zhang <zly@lyzhang.me>"]
|
||||
description = "A powerful Git assistant tool with AI-powered commit/tag/changelog generation"
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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` | 仅跳过确认提示(生成行为不变) |
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,9 +81,13 @@ async fn main() -> Result<()> {
|
||||
|
||||
// Apply the global color decision before any output is produced.
|
||||
// --no-color disables colors; NO_COLOR follows the no-color.org spec
|
||||
// (any presence, regardless of value, disables color).
|
||||
// (any presence, regardless of value, disables color). Without them,
|
||||
// color keeps colored's tty auto-detection (ADR-0003): piped output
|
||||
// stays ANSI-free.
|
||||
let no_color = cli.no_color || std::env::var_os("NO_COLOR").is_some();
|
||||
colored::control::set_override(!no_color);
|
||||
if no_color {
|
||||
colored::control::set_override(false);
|
||||
}
|
||||
|
||||
// Resolve the decoration switch (issue 14): explicit flag > config >
|
||||
// default; --no-color disables decorations too (ADR-0003).
|
||||
|
||||
@@ -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