use anyhow::{anyhow, Result};
use std::path::{Path, PathBuf};
use std::process::Command;
use tokio::sync::OnceCell;
static GIT_AVAILABLE: OnceCell<bool> = OnceCell::const_new();
pub fn is_git_available() -> bool {
Command::new("git")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn git_install_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("/usr/local"))
.join(".local")
.join("git")
}
pub fn is_git_repo(path: &Path) -> bool {
Command::new("git")
.args(["-C", &path.display().to_string()])
.args(["rev-parse", "--git-dir"])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
pub fn ensure_git_installed() -> Result<()> {
if GIT_AVAILABLE.get().copied().unwrap_or(false) {
return Ok(());
}
if is_git_available() {
let _ = GIT_AVAILABLE.set(true);
return Ok(());
}
let install_result = if cfg!(target_os = "macos") {
install_git_macos()
} else if cfg!(target_os = "linux") {
install_git_linux()
} else if cfg!(target_os = "windows") {
install_git_windows()
} else {
Err(anyhow!(
"Unsupported platform: {}. Please install git manually from https://git-scm.com",
std::env::consts::OS
))
};
if install_result.is_ok() {
let _ = GIT_AVAILABLE.set(true);
}
install_result
}
fn install_git_macos() -> Result<()> {
let install_dir = git_install_dir();
let bin_dir = install_dir.join("bin");
let git_path = bin_dir.join("git");
if git_path.exists() {
return Ok(());
}
std::fs::create_dir_all(&bin_dir)?;
let arch = if std::process::Command::new("uname")
.arg("-m")
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).contains("arm"))
.unwrap_or(false)
{
"arm64"
} else {
"x86_64"
};
let tarball_name = format!("git-2.39.3-{}-bin.tar.gz", arch);
let download_url = format!("https://git-scm.com/download/mac/{}", tarball_name);
let temp_tarball = std::env::temp_dir().join(&tarball_name);
download_with_curl(&download_url, &temp_tarball)?;
let output = Command::new("tar")
.args([
"-xzf",
&temp_tarball.display().to_string(),
"-C",
&bin_dir.display().to_string(),
])
.output()?;
if !output.status.success() {
let output = Command::new("bash")
.args([
"-c",
&format!(
"cd {} && tar -xzf {}",
bin_dir.display(),
temp_tarball.display()
),
])
.output()?;
if !output.status.success() {
return Err(anyhow!("Failed to extract git tarball"));
}
}
let _ = std::fs::remove_file(&temp_tarball);
if bin_dir.join("git").exists() {
Ok(())
} else {
let output = Command::new("tar")
.args(["-xzf", &temp_tarball.display().to_string()])
.current_dir(&install_dir)
.output()?;
if output.status.success() {
let extracted_bin = install_dir.join("usr").join("bin");
if extracted_bin.exists() {
for entry in std::fs::read_dir(&extracted_bin)?.flatten() {
let _ = std::fs::rename(entry.path(), bin_dir.join(entry.file_name()));
}
}
}
if bin_dir.join("git").exists() {
Ok(())
} else {
Err(anyhow!(
"Failed to install git. Please download from https://git-scm.com/download/mac"
))
}
}
}
fn install_git_linux() -> Result<()> {
let install_dir = git_install_dir();
let bin_dir = install_dir.join("bin");
let git_path = bin_dir.join("git");
if git_path.exists() {
return Ok(());
}
std::fs::create_dir_all(&bin_dir)?;
let version = "2.39.3";
let arch = if std::process::Command::new("uname")
.arg("-m")
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).contains("x86_64"))
.unwrap_or(true)
{
"amd64"
} else {
"386"
};
let tarball_name = format!("git-{}-linux-{}.tar.gz", version, arch);
let download_url = format!(
"https://github.com/git/git/releases/download/v{}/{}",
version, tarball_name
);
let temp_tarball = std::env::temp_dir().join(&tarball_name);
if download_with_curl(&download_url, &temp_tarball).is_err() {
let fallback_url = format!("https://git-scm.com/downloads?file=git-{}", tarball_name);
download_with_curl(&fallback_url, &temp_tarball)?;
}
let output = Command::new("tar")
.args([
"-xzf",
&temp_tarball.display().to_string(),
"-C",
&bin_dir.display().to_string(),
])
.output()?;
if !output.status.success() {
let temp_dir = std::env::temp_dir().join("git-extract");
let _ = std::fs::create_dir_all(&temp_dir);
let output = Command::new("tar")
.args([
"-xzf",
&temp_tarball.display().to_string(),
"-C",
&temp_dir.display().to_string(),
])
.output()?;
if output.status.success() {
for path in walkdir(&temp_dir) {
if let Some(name) = path.file_name() {
let name_str = name.to_string_lossy();
if name_str.starts_with("git-") && path.extension().is_none() {
let _ = std::fs::copy(&path, bin_dir.join("git"));
}
}
}
}
}
let _ = std::fs::remove_file(&temp_tarball);
if bin_dir.join("git").exists() {
Ok(())
} else {
Err(anyhow!(
"Failed to install git automatically.\n\n\
Please install git via your system's package manager or download from:\n\
https://git-scm.com/download/linux"
))
}
}
fn install_git_windows() -> Result<()> {
let install_dir = git_install_dir();
let bin_dir = install_dir.join("bin");
let git_exe = bin_dir.join("git.exe");
if git_exe.exists() {
return Ok(());
}
std::fs::create_dir_all(&bin_dir)?;
let version = "2.39.3.windows.1";
let zip_name = format!("MinGit-{}-portable.zip", version);
let download_url = format!(
"https://github.com/git-for-windows/git/releases/download/{}/{}",
version, zip_name
);
let temp_zip = std::env::temp_dir().join(&zip_name);
download_with_curl(&download_url, &temp_zip)?;
let output = Command::new("tar")
.args([
"-xf",
&temp_zip.display().to_string(),
"-C",
&bin_dir.display().to_string(),
])
.output()?;
if !output.status.success() {
let ps_script = format!(
"Expand-Archive -Path '{}' -DestinationPath '{}' -Force",
temp_zip.display(),
bin_dir.display()
);
let output = Command::new("powershell")
.args(["-Command", &ps_script])
.output()?;
if !output.status.success() {
return Err(anyhow!("Failed to extract git zip archive"));
}
}
let extracted_dir = bin_dir.join(format!("MinGit-{}", version));
if extracted_dir.exists() {
for entry in std::fs::read_dir(&extracted_dir)?.flatten() {
let dest = bin_dir.join(entry.file_name());
let _ = std::fs::rename(entry.path(), dest);
}
let _ = std::fs::remove_dir(&extracted_dir);
}
let _ = std::fs::remove_file(&temp_zip);
if git_exe.exists() {
let cmd_dir = bin_dir.join("cmd");
std::fs::create_dir_all(&cmd_dir)?;
let _ = std::fs::copy(&git_exe, cmd_dir.join("git.exe"));
Ok(())
} else {
Err(anyhow!(
"Failed to install git automatically.\n\n\
Please download and install Git from:\n\
https://git-scm.com/download/win"
))
}
}
fn download_with_curl(url: &str, path: &std::path::Path) -> Result<()> {
let output = Command::new("curl")
.args([
"-L",
"--fail",
"--retry",
"3",
"--retry-delay",
"2",
"-o",
&path.display().to_string(),
url,
])
.output()?;
if output.status.success() && path.exists() {
return Ok(());
}
let output = Command::new("wget")
.args(["-O", &path.display().to_string(), url])
.output()?;
if output.status.success() && path.exists() {
return Ok(());
}
Err(anyhow!(
"Failed to download git from {}.\n\
Please check your internet connection and try again.\n\
Or download manually from https://git-scm.com",
url
))
}
fn walkdir(dir: &Path) -> Vec<std::path::PathBuf> {
let mut files = Vec::new();
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
if path.is_dir() {
files.extend(walkdir(&path));
} else {
files.push(path);
}
}
}
files
}
fn run_git(repo_path: &Path, args: &[&str]) -> Result<(bool, String, String)> {
ensure_git_installed()?;
let output = Command::new("git")
.args(["-C", &repo_path.display().to_string()])
.args(args)
.output()
.map_err(|e| anyhow!("Failed to execute git: {}", e))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
Ok((output.status.success(), stdout, stderr))
}
#[derive(Debug, Clone)]
pub struct RepoStatus {
pub branch: String,
pub commit: String,
pub is_worktree: bool,
pub is_dirty: bool,
pub dirty_count: usize,
}
pub fn get_status(repo_path: &Path) -> Result<RepoStatus> {
let (success, stdout, _) = run_git(repo_path, &["rev-parse", "--abbrev-ref", "HEAD"])?;
let branch = if success {
stdout.trim().to_string()
} else {
"(detached)".to_string()
};
let (_, commit, _) = run_git(repo_path, &["log", "--oneline", "-1", "--no-decorate"])?;
let commit = commit.trim().to_string();
let (_, git_dir, _) = run_git(repo_path, &["rev-parse", "--git-dir"])?;
let is_worktree = git_dir.trim().contains(".git/worktrees");
let (_, status_output, _) = run_git(repo_path, &["status", "--porcelain", "--short"])?;
let dirty_count = status_output.lines().filter(|l| !l.is_empty()).count();
let is_dirty = dirty_count > 0;
Ok(RepoStatus {
branch,
commit,
is_worktree,
is_dirty,
dirty_count,
})
}
#[derive(Debug, Clone)]
pub struct CommitInfo {
pub id: String,
pub message: String,
pub author: String,
pub date: String,
}
pub fn get_log(repo_path: &Path, max_count: usize) -> Result<Vec<CommitInfo>> {
let format = "%H|%s|%an|%ad";
let date_format = "%Y-%m-%d %H:%M";
let args = [
"log",
&format!("--format={}", format),
&format!("--date=format:{}", date_format),
&format!("-{}", max_count),
];
let (_, stdout, _) = run_git(repo_path, &args)?;
let commits: Vec<CommitInfo> = stdout
.lines()
.filter_map(|line| {
let parts: Vec<&str> = line.splitn(4, '|').collect();
if parts.len() >= 4 {
Some(CommitInfo {
id: parts[0].to_string(),
message: parts[1].to_string(),
author: parts[2].to_string(),
date: parts[3].to_string(),
})
} else {
None
}
})
.collect();
Ok(commits)
}
#[derive(Debug, Clone)]
pub struct BranchInfo {
pub name: String,
pub is_current: bool,
}
pub fn list_branches(repo_path: &Path) -> Result<Vec<BranchInfo>> {
let (_, stdout, _) = run_git(repo_path, &["branch"])?;
let branches: Vec<BranchInfo> = stdout
.lines()
.filter_map(|line| {
let line = line.trim();
if line.is_empty() {
return None;
}
let is_current = line.starts_with('*');
let name = line.trim_start_matches(['*', ' ']).to_string();
Some(BranchInfo { name, is_current })
})
.collect();
Ok(branches)
}
pub fn create_branch(repo_path: &Path, name: &str, base: &str) -> Result<()> {
let (success, _, stderr) = run_git(repo_path, &["checkout", "-b", name, base])?;
if !success && !stderr.is_empty() {
return Err(anyhow!("Failed to create branch: {}", stderr));
}
Ok(())
}
pub fn delete_branch(repo_path: &Path, name: &str) -> Result<()> {
let (success, _, _) = run_git(repo_path, &["branch", "-d", name])?;
if success {
return Ok(());
}
let (success, _, stderr) = run_git(repo_path, &["branch", "-D", name])?;
if !success {
return Err(anyhow!("Failed to delete branch: {}", stderr));
}
Ok(())
}
#[derive(Debug, Clone)]
pub struct WorktreeInfo {
pub path: String,
pub branch: String,
pub is_bare: bool,
pub is_detached: bool,
}
pub fn list_worktrees(repo_path: &Path) -> Result<Vec<WorktreeInfo>> {
let (_, stdout, _) = run_git(repo_path, &["worktree", "list", "--porcelain"])?;
let mut worktrees = Vec::new();
let mut current_path = String::new();
let mut current_branch = String::new();
let mut is_bare = false;
let mut is_detached = false;
for line in stdout.lines() {
if let Some(path) = line.strip_prefix("worktree ") {
if !current_path.is_empty() {
worktrees.push(WorktreeInfo {
path: current_path.clone(),
branch: current_branch.clone(),
is_bare,
is_detached,
});
}
current_path = path.to_string();
current_branch.clear();
is_bare = false;
is_detached = false;
} else if let Some(branch) = line.strip_prefix("branch refs/heads/") {
current_branch = branch.to_string();
} else if line == "bare" {
is_bare = true;
} else if line == "detached" {
is_detached = true;
}
}
if !current_path.is_empty() {
worktrees.push(WorktreeInfo {
path: current_path,
branch: current_branch,
is_bare,
is_detached,
});
}
Ok(worktrees)
}
pub fn create_worktree(
repo_path: &Path,
branch: &str,
path: &Path,
new_branch: bool,
) -> Result<()> {
let path_str = path.display().to_string();
let args: Vec<&str> = if new_branch {
vec!["worktree", "add", "-b", branch, &path_str]
} else {
vec!["worktree", "add", &path_str, branch]
};
let (success, _, stderr) = run_git(repo_path, &args)?;
if !success {
return Err(anyhow!("Failed to create worktree: {}", stderr));
}
Ok(())
}
pub fn remove_worktree(repo_path: &Path, path: &Path, force: bool) -> Result<()> {
let path_str = path.display().to_string();
let args: Vec<&str> = if force {
vec!["worktree", "remove", "--force", &path_str]
} else {
vec!["worktree", "remove", &path_str]
};
let (success, _, stderr) = run_git(repo_path, &args)?;
if !success {
return Err(anyhow!("Failed to remove worktree: {}", stderr));
}
Ok(())
}
pub fn get_git_dir(repo_path: &Path) -> Result<String> {
let (_, stdout, _) = run_git(repo_path, &["rev-parse", "--git-dir"])?;
Ok(stdout.trim().to_string())
}
pub fn get_diff(repo_path: &Path, target: Option<&str>) -> Result<String> {
let args: Vec<&str> = if let Some(t) = target {
vec!["diff", t]
} else {
vec!["diff", "--stat"]
};
let (_, stdout, _) = run_git(repo_path, &args)?;
Ok(stdout)
}
#[derive(Debug, Clone)]
pub struct StashInfo {
pub index: usize,
pub message: String,
}
pub fn list_stashes(repo_path: &Path) -> Result<Vec<StashInfo>> {
let (_, stdout, _) = run_git(repo_path, &["stash", "list", "--format=%H|%gd|%s"])?;
let stashes: Vec<StashInfo> = stdout
.lines()
.filter_map(|line| {
let parts: Vec<&str> = line.splitn(3, '|').collect();
if parts.len() >= 3 {
Some(StashInfo {
index: parts[1].parse().unwrap_or(0),
message: parts[2].to_string(),
})
} else {
None
}
})
.collect();
Ok(stashes)
}
pub fn stash(repo_path: &Path, message: Option<&str>, include_untracked: bool) -> Result<()> {
let mut args = vec!["stash", "push"];
if include_untracked {
args.push("-u");
}
if let Some(msg) = message {
args.push("-m");
args.push(msg);
}
let (success, _, stderr) = run_git(repo_path, &args)?;
if !success {
return Err(anyhow!("Failed to stash: {}", stderr));
}
Ok(())
}