feat(commands):为所有命令添加config_path参数支持,实现自定义配置文件路径

♻️ refactor(config):重构ConfigManager,添加with_path_fresh方法用于初始化新配置
🔧 fix(git):改进跨平台路径处理,增强git仓库检测的鲁棒性
 test(tests):添加全面的集成测试,覆盖所有命令和跨平台场景
This commit is contained in:
2026-02-14 14:28:11 +08:00
parent 3c925d8268
commit e822ba1f54
14 changed files with 1152 additions and 272 deletions

View File

@@ -47,6 +47,12 @@ impl CommitBuilder {
self
}
/// Set scope (optional)
pub fn scope_opt(mut self, scope: Option<String>) -> Self {
self.scope = scope;
self
}
/// Set description
pub fn description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
@@ -59,6 +65,12 @@ impl CommitBuilder {
self
}
/// Set body (optional)
pub fn body_opt(mut self, body: Option<String>) -> Self {
self.body = body;
self
}
/// Set footer
pub fn footer(mut self, footer: impl Into<String>) -> Self {
self.footer = Some(footer.into());

View File

@@ -1,6 +1,6 @@
use anyhow::{bail, Context, Result};
use git2::{Repository, Signature, StatusOptions, Config, Oid, ObjectType};
use std::path::{Path, PathBuf};
use std::path::{Path, PathBuf, Component};
use std::collections::HashMap;
use tempfile;
@@ -8,7 +8,166 @@ pub mod changelog;
pub mod commit;
pub mod tag;
/// Git repository wrapper with enhanced cross-platform support
#[cfg(target_os = "windows")]
use std::os::windows::ffi::OsStringExt;
fn normalize_path_for_git2(path: &Path) -> PathBuf {
let mut normalized = path.to_path_buf();
#[cfg(target_os = "windows")]
{
let path_str = path.to_string_lossy();
if path_str.starts_with(r"\\?\") {
if let Some(stripped) = path_str.strip_prefix(r"\\?\") {
normalized = PathBuf::from(stripped);
}
}
if path_str.starts_with(r"\\?\UNC\") {
if let Some(stripped) = path_str.strip_prefix(r"\\?\UNC\") {
normalized = PathBuf::from(format!(r"\\{}", stripped));
}
}
}
normalized
}
fn get_absolute_path<P: AsRef<Path>>(path: P) -> Result<PathBuf> {
let path = path.as_ref();
if path.is_absolute() {
return Ok(normalize_path_for_git2(path));
}
let current_dir = std::env::current_dir()
.with_context(|| "Failed to get current directory")?;
let absolute = current_dir.join(path);
Ok(normalize_path_for_git2(&absolute))
}
fn resolve_path_without_canonicalize(path: &Path) -> PathBuf {
let mut components = Vec::new();
for component in path.components() {
match component {
Component::ParentDir => {
if !components.is_empty() && components.last() != Some(&Component::ParentDir) {
components.pop();
} else {
components.push(component);
}
}
Component::CurDir => {}
_ => components.push(component),
}
}
let mut result = PathBuf::new();
for component in components {
result.push(component.as_os_str());
}
normalize_path_for_git2(&result)
}
fn try_open_repo_with_git2(path: &Path) -> Result<Repository> {
let normalized = normalize_path_for_git2(path);
let discover_opts = git2::RepositoryOpenFlags::empty();
let ceiling_dirs: [&str; 0] = [];
let repo = Repository::open_ext(&normalized, discover_opts, &ceiling_dirs)
.or_else(|_| Repository::discover(&normalized))
.or_else(|_| Repository::open(&normalized));
repo.map_err(|e| anyhow::anyhow!("git2 failed: {}", e))
}
fn try_open_repo_with_git_cli(path: &Path) -> Result<Repository> {
let output = std::process::Command::new("git")
.args(&["rev-parse", "--show-toplevel"])
.current_dir(path)
.output()
.context("Failed to execute git command")?;
if !output.status.success() {
bail!("git CLI failed to find repository");
}
let stdout = String::from_utf8_lossy(&output.stdout);
let git_root = stdout.trim();
if git_root.is_empty() {
bail!("git CLI returned empty path");
}
let git_root_path = PathBuf::from(git_root);
let normalized = normalize_path_for_git2(&git_root_path);
Repository::open(&normalized)
.with_context(|| format!("Failed to open repo from git CLI path: {:?}", normalized))
}
fn diagnose_repo_issue(path: &Path) -> String {
let mut issues = Vec::new();
if !path.exists() {
issues.push(format!("Path does not exist: {:?}", path));
} else if !path.is_dir() {
issues.push(format!("Path is not a directory: {:?}", path));
}
let git_dir = path.join(".git");
if git_dir.exists() {
if git_dir.is_dir() {
issues.push("Found .git directory".to_string());
let config_file = git_dir.join("config");
if config_file.exists() {
issues.push("Git config file exists".to_string());
} else {
issues.push("WARNING: Git config file missing".to_string());
}
} else {
issues.push("Found .git file (submodule or worktree)".to_string());
}
} else {
issues.push("No .git found in current directory".to_string());
let mut current = path;
let mut depth = 0;
while let Some(parent) = current.parent() {
depth += 1;
if depth > 20 {
break;
}
let parent_git = parent.join(".git");
if parent_git.exists() {
issues.push(format!("Found .git in parent directory: {:?}", parent));
break;
}
current = parent;
}
}
#[cfg(target_os = "windows")]
{
let path_str = path.to_string_lossy();
if path_str.starts_with(r"\\?\") {
issues.push("Path has Windows extended-length prefix (\\\\?\\)".to_string());
}
if path_str.contains('\\') && path_str.contains('/') {
issues.push("WARNING: Path has mixed path separators".to_string());
}
}
if let Ok(current_dir) = std::env::current_dir() {
issues.push(format!("Current working directory: {:?}", current_dir));
}
issues.join("\n ")
}
pub struct GitRepo {
repo: Repository,
path: PathBuf,
@@ -16,54 +175,45 @@ pub struct GitRepo {
}
impl GitRepo {
/// Open a git repository
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
let path = path.as_ref();
// Enhanced cross-platform path handling
let absolute_path = if let Ok(canonical) = path.canonicalize() {
canonical
} else {
// Fallback: convert to absolute path without canonicalization
if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()?.join(path)
}
};
// Try multiple git repository discovery strategies for cross-platform compatibility
let repo = Repository::discover(&absolute_path)
.or_else(|discover_err| {
// Try direct open as fallback
Repository::open(&absolute_path).map_err(|open_err| {
// Provide detailed error information for debugging
anyhow::anyhow!(
"Git repository discovery failed:\n\
Discovery error: {}\n\
Direct open error: {}\n\
Path attempted: {:?}\n\
Current directory: {:?}",
discover_err, open_err, absolute_path, std::env::current_dir()
)
})
})
.with_context(|| {
format!(
"Failed to open git repository at '{:?}'. Please ensure:\n\
1. The directory contains a valid '.git' folder\n\
2. The directory is set as safe (run: git config --global --add safe.directory \"{}\")\n\
3. You have proper permissions to access the repository",
absolute_path,
absolute_path.display()
)
let absolute_path = get_absolute_path(path)?;
let resolved_path = resolve_path_without_canonicalize(&absolute_path);
let repo = try_open_repo_with_git2(&resolved_path)
.or_else(|git2_err| {
try_open_repo_with_git_cli(&resolved_path)
.map_err(|cli_err| {
let diagnosis = diagnose_repo_issue(&resolved_path);
anyhow::anyhow!(
"Failed to open git repository:\n\
\n\
=== git2 Error ===\n {}\n\
\n\
=== git CLI Error ===\n {}\n\
\n\
=== Diagnosis ===\n {}\n\
\n\
=== Suggestions ===\n\
1. Ensure you are inside a git repository\n\
2. Run: git status (to verify git works)\n\
3. Run: git config --global --add safe.directory \"*\"\n\
4. Check file permissions",
git2_err, cli_err, diagnosis
)
})
})?;
let repo_path = repo.workdir()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| resolved_path.clone());
let config = repo.config().ok();
Ok(Self {
repo,
path: absolute_path,
path: normalize_path_for_git2(&repo_path),
config,
})
}
@@ -718,20 +868,28 @@ impl StatusSummary {
}
}
/// Find git repository starting from path and walking up
pub fn find_repo<P: AsRef<Path>>(start_path: P) -> Result<GitRepo> {
let start_path = start_path.as_ref();
// Try the starting path first
if let Ok(repo) = GitRepo::open(start_path) {
let absolute_start = get_absolute_path(start_path)?;
let resolved_start = resolve_path_without_canonicalize(&absolute_start);
if let Ok(repo) = GitRepo::open(&resolved_start) {
return Ok(repo);
}
// Walk up the directory tree to find a git repository
let mut current = start_path;
let mut current = resolved_start.as_path();
let mut attempted_paths = vec![current.to_string_lossy().to_string()];
let max_depth = 50;
let mut depth = 0;
while let Some(parent) = current.parent() {
depth += 1;
if depth > max_depth {
break;
}
attempted_paths.push(parent.to_string_lossy().to_string());
if let Ok(repo) = GitRepo::open(parent) {
@@ -740,18 +898,44 @@ pub fn find_repo<P: AsRef<Path>>(start_path: P) -> Result<GitRepo> {
current = parent;
}
// Provide detailed error information for debugging
if let Ok(output) = std::process::Command::new("git")
.args(&["rev-parse", "--show-toplevel"])
.current_dir(&resolved_start)
.output()
{
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
let git_root = stdout.trim();
if !git_root.is_empty() {
if let Ok(repo) = GitRepo::open(git_root) {
return Ok(repo);
}
}
}
}
let diagnosis = diagnose_repo_issue(&resolved_start);
bail!(
"No git repository found starting from {:?}.\n\
Paths attempted:\n {}\n\
Current directory: {:?}\n\
Please ensure:\n\
1. You are in a git repository or its subdirectory\n\
2. The repository has a valid .git folder\n\
3. You have proper permissions to access the repository",
start_path,
"No git repository found.\n\
\n\
=== Starting Path ===\n {:?}\n\
\n\
=== Paths Attempted ===\n {}\n\
\n\
=== Current Directory ===\n {:?}\n\
\n\
=== Diagnosis ===\n {}\n\
\n\
=== Suggestions ===\n\
1. Ensure you are inside a git repository (run: git status)\n\
2. Initialize a new repo: git init\n\
3. Clone an existing repo: git clone <url>\n\
4. Check if .git directory exists and is accessible",
resolved_start,
attempted_paths.join("\n "),
std::env::current_dir()
std::env::current_dir().unwrap_or_default(),
diagnosis
)
}

View File

@@ -281,8 +281,9 @@ pub fn delete_tag(repo: &GitRepo, name: &str, remote: Option<&str>) -> Result<()
if let Some(remote) = remote {
use std::process::Command;
let refspec = format!(":refs/tags/{}", name);
let output = Command::new("git")
.args(&["push", remote, ":refs/tags/{}"])
.args(&["push", remote, &refspec])
.current_dir(repo.path())
.output()?;