use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Context, Result};
pub struct VersionControl {
root: PathBuf,
}
impl VersionControl {
pub fn new(root: impl AsRef<Path>) -> Self {
Self {
root: root.as_ref().to_path_buf(),
}
}
pub fn init(&self) -> Result<()> {
if !self.root.join(".git").exists() {
tracing::info!(root = %self.root.display(), "Initializing evolution git repo");
self.git(&["init"])?;
self.git(&["config", "user.email", "evolver@collet"])?;
self.git(&["config", "user.name", "Collet Evolver"])?;
}
self.git(&["add", "-A"])?;
match self.git(&["commit", "-m", "Initial workspace state"]) {
Ok(_) => {
self.git(&["tag", "evo-0"])?;
tracing::info!("Created initial commit with tag evo-0");
}
Err(_) => {
let _ = self.git(&["tag", "evo-0"]);
}
}
Ok(())
}
pub fn commit(&self, message: &str, tag: Option<&str>) -> Result<()> {
self.git(&["add", "-A"])?;
match self.git(&["commit", "-m", message]) {
Ok(_) => tracing::debug!(message, "Committed"),
Err(_) => tracing::debug!(message, "Nothing to commit"),
}
if let Some(t) = tag {
self.git(&["tag", "-f", t])?;
tracing::debug!(tag = t, "Tagged");
}
Ok(())
}
pub fn rollback(&self, reference: &str) -> Result<()> {
tracing::info!(reference, "Rolling back workspace");
self.git(&["checkout", reference, "--", "."])?;
self.git(&["add", "-A"])?;
let _ = self.git(&["commit", "-m", &format!("rollback to {reference}")]);
Ok(())
}
pub fn rollback_to_tag(&self, tag: &str) -> Result<()> {
self.rollback(tag)
}
pub fn get_diff(&self, from_ref: &str, to_ref: &str) -> Result<String> {
self.git(&["diff", from_ref, to_ref])
}
pub fn get_diff_stat(&self, from_ref: &str, to_ref: &str) -> Result<String> {
self.git(&["diff", "--stat", from_ref, to_ref])
}
pub fn get_log(&self, n: usize) -> Result<String> {
self.git(&["log", "--oneline", &format!("-{n}")])
}
pub fn list_tags(&self) -> Result<Vec<String>> {
let output = self.git(&["tag", "-l", "evo-*", "--sort=-version:refname"])?;
Ok(output
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect())
}
pub fn show_file_at(&self, reference: &str, filepath: &str) -> Result<String> {
self.git(&["show", &format!("{reference}:{filepath}")])
}
pub fn checkout_copy(&self, reference: &str, dest: &Path) -> Result<()> {
self.git(&[
"worktree",
"add",
"--detach",
&dest.display().to_string(),
reference,
])?;
Ok(())
}
pub fn remove_copy(&self, dest: &Path) -> Result<()> {
self.git(&["worktree", "remove", &dest.display().to_string(), "--force"])?;
Ok(())
}
fn git(&self, args: &[&str]) -> Result<String> {
let output = Command::new("git")
.args(args)
.current_dir(&self.root)
.output()
.with_context(|| format!("Failed to run: git {}", args.join(" ")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("nothing to commit") {
return Ok(String::new());
}
anyhow::bail!("git {}: {}", args.join(" "), stderr.trim());
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn test_get_log_no_git_repo() {
let dir = std::env::temp_dir().join("collet_vc_test_log");
let vc = VersionControl::new(&dir);
let result = vc.get_log(5);
assert!(result.is_err() || result.unwrap().is_empty());
}
#[test]
fn test_list_tags_no_git_repo() {
let dir = std::env::temp_dir().join("collet_vc_test_tags");
let vc = VersionControl::new(&dir);
let result = vc.list_tags();
assert!(result.is_err() || result.unwrap().is_empty());
}
#[test]
fn test_checkout_copy_no_git_repo() {
let dir = std::env::temp_dir().join("collet_vc_test_co");
let dest = std::env::temp_dir().join("collet_vc_test_co_dest");
let vc = VersionControl::new(&dir);
let result = vc.checkout_copy("HEAD", Path::new(&dest));
assert!(result.is_err());
}
#[test]
fn test_remove_copy_no_git_repo() {
let dir = std::env::temp_dir().join("collet_vc_test_rm");
let dest = std::env::temp_dir().join("collet_vc_test_rm_dest");
let vc = VersionControl::new(&dir);
let result = vc.remove_copy(Path::new(&dest));
assert!(result.is_err());
}
}