✨ feat(commands):为所有命令添加config_path参数支持,实现自定义配置文件路径
♻️ refactor(config):重构ConfigManager,添加with_path_fresh方法用于初始化新配置 🔧 fix(git):改进跨平台路径处理,增强git仓库检测的鲁棒性 ✅ test(tests):添加全面的集成测试,覆盖所有命令和跨平台场景
This commit is contained in:
@@ -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());
|
||||
|
||||
300
src/git/mod.rs
300
src/git/mod.rs
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user