use std::{
path::{Path, PathBuf},
process::Command,
};
use crate::error::{DotlingError, Result};
#[derive(Debug, PartialEq, Eq)]
pub enum PullResult {
UpToDate,
Updated(usize),
Conflict,
}
pub struct Git {
repo_root: PathBuf,
}
impl Git {
pub fn new(repo_root: PathBuf) -> Self {
Self { repo_root }
}
fn run(&self, args: &[&str]) -> Result<String> {
run_git_at(&self.repo_root, args)
}
pub fn init(&self) -> Result<()> {
self.run(&["init"])?;
Ok(())
}
#[allow(dead_code)]
pub fn add_remote(&self, name: &str, url: &str) -> Result<()> {
self.run(&["remote", "add", name, url])?;
Ok(())
}
pub fn stage(&self, path: &Path) -> Result<()> {
let path_str = path.to_string_lossy();
self.run(&["add", &path_str])?;
Ok(())
}
pub fn stage_all(&self) -> Result<()> {
self.run(&["add", "-A"])?;
Ok(())
}
pub fn commit(&self, message: &str) -> Result<()> {
let status = self.run(&["status", "--porcelain"]);
match status {
Ok(output) if output.trim().is_empty() => return Ok(()),
Err(e) => return Err(e),
_ => {}
}
self.run(&["commit", "-m", message])?;
Ok(())
}
pub fn pull_rebase(&self) -> Result<PullResult> {
let result = Command::new("git")
.args(["pull", "--rebase"])
.current_dir(&self.repo_root)
.output()
.map_err(|_| DotlingError::Git("git is not installed or not in PATH".to_string()))?;
let stdout = String::from_utf8_lossy(&result.stdout);
let stderr = String::from_utf8_lossy(&result.stderr);
if !result.status.success() {
let combined = format!("{stdout}{stderr}");
if combined.contains("CONFLICT") || combined.contains("conflict") {
return Ok(PullResult::Conflict);
}
return Err(DotlingError::Git(stderr.trim().to_string()));
}
if stdout.contains("Already up to date") || stdout.contains("Already up-to-date") {
return Ok(PullResult::UpToDate);
}
let file_count = stdout
.lines()
.filter(|l| l.contains('|') || l.starts_with(' '))
.count();
Ok(PullResult::Updated(file_count.max(1)))
}
pub fn push(&self) -> Result<()> {
let branch = self.current_branch()?;
self.run(&["push", "-u", "origin", &branch])?;
Ok(())
}
pub fn current_branch(&self) -> Result<String> {
let output = self.run(&["rev-parse", "--abbrev-ref", "HEAD"])?;
Ok(output.trim().to_string())
}
pub fn has_remote(&self) -> Result<bool> {
let output = self.run(&["remote"])?;
Ok(!output.trim().is_empty())
}
#[allow(dead_code)]
pub fn changed_files(&self) -> Result<Vec<String>> {
let output = self.run(&["status", "--porcelain"])?;
let files: Vec<String> = output
.lines()
.filter(|l| !l.is_empty())
.map(|l| l[3..].to_string())
.collect();
Ok(files)
}
pub fn clone(url: &str, dest: &Path) -> Result<()> {
let dest_str = dest.to_string_lossy();
let output = Command::new("git")
.args(["clone", url, &dest_str])
.output()
.map_err(|_| DotlingError::Git("git is not installed or not in PATH".to_string()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(DotlingError::Git(stderr.trim().to_string()));
}
Ok(())
}
}
fn run_git_at(cwd: &Path, args: &[&str]) -> Result<String> {
let output = Command::new("git")
.args(args)
.current_dir(cwd)
.output()
.map_err(|_| DotlingError::Git("git is not installed or not in PATH".to_string()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(DotlingError::Git(stderr.trim().to_string()));
}
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout.to_string())
}