feat(commands): 添加 git credential helper 命令

This commit is contained in:
2026-07-16 11:26:41 +08:00
parent 14ebb6857a
commit b6bc091502
8 changed files with 1604 additions and 48 deletions

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "quicommit" name = "quicommit"
version = "0.3.2" version = "0.4.0"
edition = "2024" edition = "2024"
authors = ["Sidney Zhang <zly@lyzhang.me>"] authors = ["Sidney Zhang <zly@lyzhang.me>"]
description = "A powerful Git assistant tool with AI-powered commit/tag/changelog generation(alpha version)" description = "A powerful Git assistant tool with AI-powered commit/tag/changelog generation(alpha version)"

View File

@@ -34,12 +34,6 @@
- 支持 `quicommit credential get|store|erase` 子命令 - 支持 `quicommit credential get|store|erase` 子命令
- 与系统密钥环无缝对接,复用已有的 `KeyringManager` - 与系统密钥环无缝对接,复用已有的 `KeyringManager`
- [ ] **凭证管理 CLI**
- `quicommit credential list` — 列出所有已存储的凭证
- `quicommit credential add` — 手动添加凭证(用户名 + 密码/Token
- `quicommit credential remove` — 删除指定凭证
- `quicommit credential status` — 查看凭证管理状态
- [ ] **跨平台支持** - [ ] **跨平台支持**
- Windows集成 Windows Credential Manager - Windows集成 Windows Credential Manager
- macOS集成 Keychain - macOS集成 Keychain

458
src/commands/credential.rs Normal file
View File

@@ -0,0 +1,458 @@
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use std::io::{self, BufRead, Write};
use std::path::PathBuf;
use crate::config::manager::ConfigManager;
use crate::config::{TokenConfig, TokenType};
/// Git credential helper command.
///
/// Implements the git credential helper protocol
/// (https://git-scm.com/docs/gitcredentials). Intended to be invoked by git
/// via `credential.helper` configuration, not by end users. Hidden from the
/// main help output.
#[derive(Parser)]
#[command(hide = true)]
pub struct CredentialCommand {
#[command(subcommand)]
command: CredentialSubcommand,
}
#[derive(Subcommand)]
enum CredentialSubcommand {
/// Read attributes on stdin and output credentials on stdout.
#[command(hide = true)]
Get,
/// Read attributes (including password) on stdin and store them.
#[command(hide = true)]
Store,
/// Read attributes on stdin and erase any matching stored credentials.
#[command(hide = true)]
Erase,
}
impl CredentialCommand {
pub async fn execute(&self, config_path: Option<PathBuf>) -> Result<()> {
match &self.command {
CredentialSubcommand::Get => Self::get(config_path),
CredentialSubcommand::Store => Self::store(config_path),
CredentialSubcommand::Erase => Self::erase(config_path),
}
}
fn get_manager(config_path: &Option<PathBuf>) -> Result<ConfigManager> {
match config_path {
Some(path) => ConfigManager::with_path(path),
None => ConfigManager::new(),
}
}
/// `git credential get`: look up a PAT for the requested host and emit it
/// on stdout following the git credential helper protocol.
fn get(config_path: Option<PathBuf>) -> Result<()> {
let attrs = CredentialAttributes::from_stdin()?;
let host = match attrs.host.as_deref() {
Some(h) if !h.is_empty() => h,
_ => return Ok(()),
};
let service = host_to_service(host);
let manager = match Self::get_manager(&config_path) {
Ok(m) => m,
Err(_) => return Ok(()),
};
let (profile_name, pat) = match find_pat_for_service(&manager, &service) {
Some(tuple) => tuple,
None => return Ok(()),
};
// Prefer a git-supplied username; fall back to the profile's user_name.
let username = attrs.username.clone().or_else(|| {
manager
.get_profile(&profile_name)
.map(|p| p.user_name.clone())
});
let output = CredentialAttributes {
protocol: attrs.protocol.clone(),
host: attrs.host.clone(),
path: attrs.path.clone(),
username,
password: Some(pat),
};
output.to_stdout()?;
Ok(())
}
/// `git credential store`: persist the PAT provided by git using the
/// existing keyring-backed storage, associated with a matching profile.
fn store(config_path: Option<PathBuf>) -> Result<()> {
let attrs = CredentialAttributes::from_stdin()?;
let host = match attrs.host.as_deref() {
Some(h) if !h.is_empty() => h,
_ => return Ok(()),
};
let service = host_to_service(host);
let password = match attrs.password.as_deref() {
Some(p) if !p.is_empty() => p,
_ => return Ok(()),
};
let mut manager = Self::get_manager(&config_path)?;
let profile_name = match find_profile_for_store(&manager, attrs.username.as_deref()) {
Some(name) => name,
None => return Ok(()),
};
// Store PAT in keyring using the existing profile-bound logic.
if let Err(e) = manager.store_pat_for_profile(&profile_name, &service, password) {
eprintln!("[quicommit credential] failed to store PAT: {}", e);
return Ok(());
}
// Register the token in the profile config if not already present.
let already_has = manager
.get_profile(&profile_name)
.map(|p| p.tokens.contains_key(&service))
.unwrap_or(false);
if !already_has {
let _ = manager.add_token_to_profile(
&profile_name,
service.clone(),
TokenConfig::new(TokenType::Personal),
);
}
manager.save()?;
Ok(())
}
/// `git credential erase`: remove any PAT stored for the requested host
/// from the keyring and the associated profile config entries.
fn erase(config_path: Option<PathBuf>) -> Result<()> {
let attrs = CredentialAttributes::from_stdin()?;
let host = match attrs.host.as_deref() {
Some(h) if !h.is_empty() => h,
_ => return Ok(()),
};
let service = host_to_service(host);
let mut manager = Self::get_manager(&config_path)?;
let profile_names: Vec<String> = manager
.list_profiles()
.into_iter()
.filter(|name| {
manager
.get_profile(name)
.map(|p| p.tokens.contains_key(&service))
.unwrap_or(false)
})
.cloned()
.collect();
for name in &profile_names {
if let Err(e) = manager.remove_token_from_profile(name, &service) {
eprintln!(
"[quicommit credential] failed to erase PAT for '{}': {}",
name, e
);
}
}
manager.save()?;
Ok(())
}
}
/// Credential attributes exchanged with git via stdin/stdout following the
/// git credential helper protocol (`key=value`, one per line, blank line ends).
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct CredentialAttributes {
pub protocol: Option<String>,
pub host: Option<String>,
pub path: Option<String>,
pub username: Option<String>,
pub password: Option<String>,
}
impl CredentialAttributes {
/// Read attributes from stdin following the git credential helper protocol.
pub fn from_stdin() -> Result<Self> {
let stdin = io::stdin();
let mut attrs = Self::default();
for line in stdin.lock().lines() {
let line =
line.context("Failed to read credential attributes from stdin")?;
if line.is_empty() {
break;
}
if let Some((key, value)) = line.split_once('=') {
match key {
"protocol" => attrs.protocol = Some(value.to_string()),
"host" => attrs.host = Some(value.to_string()),
"path" => attrs.path = Some(value.to_string()),
"username" => attrs.username = Some(value.to_string()),
"password" => attrs.password = Some(value.to_string()),
_ => {} // ignore unknown keys
}
}
}
Ok(attrs)
}
/// Write attributes to stdout following the git credential helper protocol.
pub fn to_stdout(&self) -> Result<()> {
let stdout = io::stdout();
let mut handle = stdout.lock();
if let Some(ref v) = self.protocol {
writeln!(handle, "protocol={}", v)?;
}
if let Some(ref v) = self.host {
writeln!(handle, "host={}", v)?;
}
if let Some(ref v) = self.path {
writeln!(handle, "path={}", v)?;
}
if let Some(ref v) = self.username {
writeln!(handle, "username={}", v)?;
}
if let Some(ref v) = self.password {
writeln!(handle, "password={}", v)?;
}
writeln!(handle)?; // blank line terminates the attribute list
handle.flush()?;
Ok(())
}
/// Parse attributes from a text block following the git credential helper
/// protocol. Intended for testing and non-stdin input handling.
pub fn parse_str(input: &str) -> Self {
let mut attrs = Self::default();
for line in input.lines() {
if line.is_empty() {
break;
}
if let Some((key, value)) = line.split_once('=') {
match key {
"protocol" => attrs.protocol = Some(value.to_string()),
"host" => attrs.host = Some(value.to_string()),
"path" => attrs.path = Some(value.to_string()),
"username" => attrs.username = Some(value.to_string()),
"password" => attrs.password = Some(value.to_string()),
_ => {}
}
}
}
attrs
}
/// Serialize attributes to a string following the git credential helper
/// protocol. Intended for testing and non-stdout output handling.
pub fn serialize(&self) -> String {
let mut out = String::new();
if let Some(ref v) = self.protocol {
out.push_str(&format!("protocol={}\n", v));
}
if let Some(ref v) = self.host {
out.push_str(&format!("host={}\n", v));
}
if let Some(ref v) = self.path {
out.push_str(&format!("path={}\n", v));
}
if let Some(ref v) = self.username {
out.push_str(&format!("username={}\n", v));
}
if let Some(ref v) = self.password {
out.push_str(&format!("password={}\n", v));
}
out.push('\n');
out
}
}
/// Map a git host to a service name used by the keyring-backed PAT storage.
///
/// Common git hosting services are mapped to short canonical names. Unknown
/// hosts are used as-is (lowercased, trailing slash trimmed).
pub fn host_to_service(host: &str) -> String {
let host = host.to_lowercase();
let host = host.trim_end_matches('/');
match host {
"github.com" | "www.github.com" => "github".to_string(),
"gitlab.com" | "www.gitlab.com" => "gitlab".to_string(),
"bitbucket.org" | "www.bitbucket.org" => "bitbucket".to_string(),
"codeberg.org" | "www.codeberg.org" => "codeberg".to_string(),
"gitea.com" | "www.gitea.com" => "gitea".to_string(),
"gitee.com" | "www.gitee.com" => "gitee".to_string(),
other => other.to_string(),
}
}
/// Search all profiles for one that has a PAT stored for the given service.
/// Returns the profile name and the PAT value.
fn find_pat_for_service(manager: &ConfigManager, service: &str) -> Option<(String, String)> {
for profile_name in manager.list_profiles() {
if manager.has_pat_for_profile(profile_name, service) {
if let Ok(Some(pat)) = manager.get_pat_for_profile(profile_name, service) {
return Some((profile_name.clone(), pat));
}
}
}
None
}
/// Determine which profile to use when storing a credential.
///
/// Prefers a profile whose `user_name` or `user_email` matches the
/// git-supplied username; otherwise falls back to the default profile.
fn find_profile_for_store(
manager: &ConfigManager,
username: Option<&str>,
) -> Option<String> {
if let Some(username) = username {
for name in manager.list_profiles() {
if let Some(profile) = manager.get_profile(name) {
if profile.user_name == username || profile.user_email == username {
return Some(name.clone());
}
}
}
}
manager.default_profile_name().cloned()
}
/// Extract a PAT for the given host from saved credentials across all profiles.
///
/// This is intended for use by other parts of the application (e.g. when
/// verifying access to a git hosting service) and searches every configured
/// profile for a stored PAT matching the host.
pub fn get_pat_for_host(host: &str) -> Result<Option<String>> {
let manager = ConfigManager::new()?;
let service = host_to_service(host);
Ok(find_pat_for_service(&manager, &service).map(|(_, pat)| pat))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_host_to_service_known_hosts() {
assert_eq!(host_to_service("github.com"), "github");
assert_eq!(host_to_service("www.github.com"), "github");
assert_eq!(host_to_service("gitlab.com"), "gitlab");
assert_eq!(host_to_service("bitbucket.org"), "bitbucket");
assert_eq!(host_to_service("codeberg.org"), "codeberg");
assert_eq!(host_to_service("gitea.com"), "gitea");
assert_eq!(host_to_service("gitee.com"), "gitee");
}
#[test]
fn test_host_to_service_case_insensitive() {
assert_eq!(host_to_service("GitHub.Com"), "github");
assert_eq!(host_to_service("GITLAB.COM"), "gitlab");
}
#[test]
fn test_host_to_service_trailing_slash() {
assert_eq!(host_to_service("github.com/"), "github");
assert_eq!(host_to_service("gitlab.com//"), "gitlab");
}
#[test]
fn test_host_to_service_unknown_host() {
assert_eq!(host_to_service("example.com"), "example.com");
assert_eq!(host_to_service("git.internal.corp"), "git.internal.corp");
}
#[test]
fn test_credential_attributes_parse_basic() {
let input = "protocol=https\nhost=github.com\nusername=octocat\npassword=ghp_token123\n\n";
let attrs = CredentialAttributes::parse_str(input);
assert_eq!(attrs.protocol.as_deref(), Some("https"));
assert_eq!(attrs.host.as_deref(), Some("github.com"));
assert_eq!(attrs.username.as_deref(), Some("octocat"));
assert_eq!(attrs.password.as_deref(), Some("ghp_token123"));
assert!(attrs.path.is_none());
}
#[test]
fn test_credential_attributes_parse_with_path() {
let input = "protocol=https\nhost=github.com\npath=owner/repo.git\n\n";
let attrs = CredentialAttributes::parse_str(input);
assert_eq!(attrs.path.as_deref(), Some("owner/repo.git"));
}
#[test]
fn test_credential_attributes_parse_empty() {
let attrs = CredentialAttributes::parse_str("");
assert!(attrs.protocol.is_none());
assert!(attrs.host.is_none());
assert!(attrs.username.is_none());
assert!(attrs.password.is_none());
}
#[test]
fn test_credential_attributes_parse_ignores_unknown_keys() {
let input = "protocol=https\nhost=github.com\nunknown=value\nfoo=bar\n\n";
let attrs = CredentialAttributes::parse_str(input);
assert_eq!(attrs.protocol.as_deref(), Some("https"));
assert_eq!(attrs.host.as_deref(), Some("github.com"));
}
#[test]
fn test_credential_attributes_parse_stops_at_blank_line() {
let input = "protocol=https\nhost=github.com\n\npassword=should_be_ignored\n";
let attrs = CredentialAttributes::parse_str(input);
assert_eq!(attrs.protocol.as_deref(), Some("https"));
assert_eq!(attrs.host.as_deref(), Some("github.com"));
assert!(attrs.password.is_none());
}
#[test]
fn test_credential_attributes_serialize_roundtrip() {
let attrs = CredentialAttributes {
protocol: Some("https".to_string()),
host: Some("github.com".to_string()),
path: None,
username: Some("octocat".to_string()),
password: Some("ghp_token".to_string()),
};
let serialized = attrs.serialize();
let reparsed = CredentialAttributes::parse_str(&serialized);
assert_eq!(attrs, reparsed);
}
#[test]
fn test_credential_attributes_serialize_includes_blank_line() {
let attrs = CredentialAttributes {
protocol: Some("https".to_string()),
host: Some("github.com".to_string()),
path: None,
username: None,
password: None,
};
let serialized = attrs.serialize();
assert!(serialized.ends_with("\n\n"));
assert!(serialized.contains("protocol=https\n"));
assert!(serialized.contains("host=github.com\n"));
}
#[test]
fn test_credential_attributes_default() {
let attrs = CredentialAttributes::default();
assert!(attrs.protocol.is_none());
assert!(attrs.host.is_none());
assert!(attrs.path.is_none());
assert!(attrs.username.is_none());
assert!(attrs.password.is_none());
}
}

View File

@@ -1,6 +1,7 @@
pub mod changelog; pub mod changelog;
pub mod commit; pub mod commit;
pub mod config; pub mod config;
pub mod credential;
pub mod init; pub mod init;
pub mod profile; pub mod profile;
pub mod tag; pub mod tag;

9
src/lib.rs Normal file
View File

@@ -0,0 +1,9 @@
#![allow(dead_code)]
pub mod commands;
pub mod config;
pub mod generator;
pub mod git;
pub mod i18n;
pub mod llm;
pub mod utils;

View File

@@ -5,17 +5,9 @@ use clap::{Parser, Subcommand};
use std::path::PathBuf; use std::path::PathBuf;
use tracing::debug; use tracing::debug;
mod commands; use quicommit::commands::{
mod config; changelog::ChangelogCommand, commit::CommitCommand, config::ConfigCommand,
mod generator; credential::CredentialCommand, init::InitCommand, profile::ProfileCommand, tag::TagCommand,
mod git;
mod i18n;
mod llm;
mod utils;
use commands::{
changelog::ChangelogCommand, commit::CommitCommand, config::ConfigCommand, init::InitCommand,
profile::ProfileCommand, tag::TagCommand,
}; };
/// QuiCommit - AI-powered Git assistant /// QuiCommit - AI-powered Git assistant
@@ -71,6 +63,10 @@ enum Commands {
/// Manage configuration settings /// Manage configuration settings
#[command(alias = "cfg")] #[command(alias = "cfg")]
Config(ConfigCommand), Config(ConfigCommand),
/// Git credential helper (hidden, invoked by git)
#[command(hide = true)]
Credential(CredentialCommand),
} }
#[tokio::main] #[tokio::main]
@@ -100,5 +96,6 @@ async fn main() -> Result<()> {
Commands::Changelog(cmd) => cmd.execute(config_path).await, Commands::Changelog(cmd) => cmd.execute(config_path).await,
Commands::Profile(cmd) => cmd.execute(config_path).await, Commands::Profile(cmd) => cmd.execute(config_path).await,
Commands::Config(cmd) => cmd.execute(config_path).await, Commands::Config(cmd) => cmd.execute(config_path).await,
Commands::Credential(cmd) => cmd.execute(config_path).await,
} }
} }

View File

@@ -150,11 +150,6 @@ impl KeyringManager {
.set_password(token) .set_password(token)
.context("Failed to store PAT in keyring")?; .context("Failed to store PAT in keyring")?;
eprintln!(
"[DEBUG] PAT stored in keyring: service={}, user={}",
keyring_service, keyring_user
);
Ok(()) Ok(())
} }
@@ -175,20 +170,8 @@ impl KeyringManager {
.context("Failed to create keyring entry for PAT")?; .context("Failed to create keyring entry for PAT")?;
match entry.get_password() { match entry.get_password() {
Ok(token) => { Ok(token) => Ok(Some(token)),
eprintln!( Err(keyring::Error::NoEntry) => Ok(None),
"[DEBUG] PAT retrieved from keyring: service={}, user={}",
keyring_service, keyring_user
);
Ok(Some(token))
}
Err(keyring::Error::NoEntry) => {
eprintln!(
"[DEBUG] PAT not found in keyring: service={}, user={}",
keyring_service, keyring_user
);
Ok(None)
}
Err(e) => Err(e.into()), Err(e) => Err(e.into()),
} }
} }
@@ -208,11 +191,6 @@ impl KeyringManager {
.delete_credential() .delete_credential()
.context("Failed to delete PAT from keyring")?; .context("Failed to delete PAT from keyring")?;
eprintln!(
"[DEBUG] PAT deleted from keyring: service={}, user={}",
keyring_service, keyring_user
);
Ok(()) Ok(())
} }
@@ -229,12 +207,7 @@ impl KeyringManager {
services: &[String], services: &[String],
) -> Result<()> { ) -> Result<()> {
for service in services { for service in services {
if let Err(e) = self.delete_pat(profile_name, user_email, service) { let _ = self.delete_pat(profile_name, user_email, service);
eprintln!(
"[DEBUG] Failed to delete PAT for service '{}': {}",
service, e
);
}
} }
Ok(()) Ok(())
} }

1124
tests/credential_tests.rs Normal file

File diff suppressed because it is too large Load Diff