use color_eyre::Result;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use walkdir::WalkDir;
#[derive(Debug, Clone)]
pub struct GitRepo {
path: PathBuf,
branch: String,
remote_status: Option<String>,
status: Option<String>,
missing: bool,
remote_url: Option<String>,
}
impl GitRepo {
pub fn new(path: PathBuf) -> Self {
let branch = Self::read_branch(&path);
let remote_url = Self::read_remote_url(&path);
Self {
path,
branch,
remote_status: None,
status: None,
missing: false,
remote_url,
}
}
pub fn new_missing(path: PathBuf, remote_url: Option<String>) -> Self {
Self {
path,
branch: String::new(),
remote_status: None,
status: None,
missing: true,
remote_url,
}
}
pub fn is_missing(&self) -> bool {
self.missing
}
pub fn set_missing(&mut self) {
self.missing = true;
}
pub fn set_remote_status(&mut self, remote_status: String) {
self.remote_status = Some(remote_status);
}
pub fn set_status(&mut self, status: String) {
self.status = Some(status);
}
pub fn is_loaded(&self) -> bool {
self.remote_status.is_some() && self.status.is_some()
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn name(&self) -> Option<&str> {
self.path.file_name()?.to_str()
}
pub fn parent_name(&self) -> Option<&str> {
self.path.parent()?.file_name()?.to_str()
}
pub fn display_short(&self) -> String {
match (self.parent_name(), self.name()) {
(Some(parent), Some(name)) => format!("{}/{}", parent, name),
(None, Some(name)) => name.to_string(),
_ => self.path.display().to_string(),
}
}
pub fn branch(&self) -> &str {
&self.branch
}
pub fn remote_status(&self) -> &str {
self.remote_status.as_deref().unwrap_or("loading...")
}
pub fn status(&self) -> &str {
self.status.as_deref().unwrap_or("loading...")
}
pub fn get_remote_url(&self) -> Option<String> {
self.remote_url.clone()
}
pub fn clone_repository(&self) -> Result<()> {
if !self.missing {
return Err(color_eyre::eyre::eyre!("Repository already exists"));
}
let remote_url = self.remote_url.as_ref()
.ok_or_else(|| color_eyre::eyre::eyre!("No remote URL for repository"))?;
if let Some(parent) = self.path.parent() {
fs::create_dir_all(parent)?;
}
let is_github = remote_url.contains("github.com");
let output = if is_github {
Command::new("gh")
.args(["repo", "clone", remote_url, &self.path.to_string_lossy()])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.output()
} else {
Command::new("git")
.args(["clone", remote_url, &self.path.to_string_lossy()])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.output()
}?;
if !output.status.success() {
return Err(color_eyre::eyre::eyre!("Failed to clone repository"));
}
Ok(())
}
fn read_remote_url(path: &Path) -> Option<String> {
Command::new("git")
.args(["remote", "get-url", "origin"])
.current_dir(path)
.output()
.ok()
.and_then(|output| {
if output.status.success() {
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
None
}
})
}
fn read_branch(path: &Path) -> String {
let head_path = path.join(".git").join("HEAD");
if let Ok(content) = fs::read_to_string(&head_path) {
let content = content.trim();
if let Some(branch_ref) = content.strip_prefix("ref: refs/heads/") {
return branch_ref.to_string();
}
if content.len() >= 7 {
return format!("detached@{}", &content[..7]);
}
}
"unknown".to_string()
}
pub fn read_remote_status(path: &Path) -> String {
let has_remote = Command::new("git")
.args(["remote"])
.current_dir(path)
.output()
.ok()
.and_then(|output| {
if output.status.success() {
Some(!output.stdout.is_empty())
} else {
None
}
})
.unwrap_or(false);
if !has_remote {
return "local-only".to_string();
}
let output = Command::new("git")
.args(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"])
.current_dir(path)
.output();
if let Ok(output) = output
&& output.status.success()
{
let stdout = String::from_utf8_lossy(&output.stdout);
let parts: Vec<&str> = stdout.split_whitespace().collect();
if parts.len() == 2
&& let (Ok(ahead), Ok(behind)) = (parts[0].parse::<i32>(), parts[1].parse::<i32>())
{
if ahead == 0 && behind == 0 {
return "up-to-date".to_string();
}
return format!("↑{} ↓{}", ahead, behind);
}
}
"no-tracking".to_string()
}
pub fn read_status(path: &Path) -> String {
let output = Command::new("git")
.args(["status", "--porcelain"])
.current_dir(path)
.output();
if let Ok(output) = output
&& output.status.success()
{
let stdout = String::from_utf8_lossy(&output.stdout);
if stdout.trim().is_empty() {
return "clean".to_string();
}
let mut staged = 0;
let mut unstaged = 0;
for line in stdout.lines() {
if line.len() >= 2 {
let index_status = &line[0..1];
let work_tree_status = &line[1..2];
if index_status != " " && index_status != "?" {
staged += 1;
}
if work_tree_status != " " {
unstaged += 1;
}
}
}
match (staged, unstaged) {
(0, u) if u > 0 => format!("{}M", u),
(s, 0) if s > 0 => format!("{}S", s),
(s, u) if s > 0 && u > 0 => format!("{}S {}M", s, u),
_ => "dirty".to_string(),
}
} else {
"unknown".to_string()
}
}
pub fn fetch(path: &Path, update: bool) -> Result<()> {
let output = Command::new("git")
.args(["fetch", "--all", "--prune"])
.current_dir(path)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(color_eyre::eyre::eyre!("git fetch failed: {}", stderr));
}
if update {
let merge_output = Command::new("git")
.args(["merge", "--ff-only", "@{upstream}"])
.current_dir(path)
.output()?;
if merge_output.status.success() {
let _ = Command::new("git")
.args(["submodule", "update", "--init", "--recursive"])
.current_dir(path)
.output();
}
}
Ok(())
}
}
fn is_git_repo(path: &Path) -> bool {
path.join(".git").exists()
}
pub fn find_git_repos(root: &Path) -> Vec<GitRepo> {
WalkDir::new(root)
.into_iter()
.filter_entry(|e| {
let filename = e.file_name();
if filename.to_str().is_some_and(|s| s.starts_with('.')) {
return false;
}
if filename.to_str().is_some_and(|s| s == "tmp") {
return false;
}
if let Some(parent) = e.path().parent()
&& parent != root && is_git_repo(parent)
{
return false;
}
true
})
.filter_map(|entry| entry.ok())
.filter(|entry| entry.file_type().is_dir() && is_git_repo(entry.path()))
.map(|entry| {
let path = entry.path().canonicalize().unwrap_or_else(|_| entry.path().to_path_buf());
GitRepo::new(path)
})
.collect()
}