feat(commands): 添加 git credential helper 命令
This commit is contained in:
@@ -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)"
|
||||||
|
|||||||
@@ -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
458
src/commands/credential.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
9
src/lib.rs
Normal 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;
|
||||||
19
src/main.rs
19
src/main.rs
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
1124
tests/credential_tests.rs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user