collet 0.1.1

Relentless agentic coding orchestrator with zero-drop agent loops
Documentation
//! Git-based version control for evolution workspaces.
//!
//! Each evolution cycle produces a tagged commit (`evo-1`, `evo-2`, …)
//! so that mutations are auditable, diff-able, and rollback-safe.
//!
//! Complements Collet's existing `CheckpointManager` (which provides
//! lightweight within-session rewind via file snapshots).

use std::path::{Path, PathBuf};
use std::process::Command;

use anyhow::{Context, Result};

/// Manages git history on an evolution workspace directory.
pub struct VersionControl {
    root: PathBuf,
}

impl VersionControl {
    pub fn new(root: impl AsRef<Path>) -> Self {
        Self {
            root: root.as_ref().to_path_buf(),
        }
    }

    /// Initialize a git repo in the workspace (idempotent).
    ///
    /// Creates the initial commit and tags it `evo-0`.
    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(_) => {
                // Already committed — ensure tag exists
                let _ = self.git(&["tag", "evo-0"]);
            }
        }
        Ok(())
    }

    /// Stage all changes, commit, and optionally tag.
    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(())
    }

    /// Restore workspace content from `reference` as a NEW commit.
    ///
    /// Unlike `git reset --hard`, this preserves the rejected version in
    /// history so it can be inspected or reused later.
    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(())
    }

    /// Restore workspace to the state at `tag`.
    pub fn rollback_to_tag(&self, tag: &str) -> Result<()> {
        self.rollback(tag)
    }

    /// Get the diff between two refs.
    pub fn get_diff(&self, from_ref: &str, to_ref: &str) -> Result<String> {
        self.git(&["diff", from_ref, to_ref])
    }

    /// Get a summary diff (stat) between two refs.
    pub fn get_diff_stat(&self, from_ref: &str, to_ref: &str) -> Result<String> {
        self.git(&["diff", "--stat", from_ref, to_ref])
    }

    /// Get recent git log (oneline).
    pub fn get_log(&self, n: usize) -> Result<String> {
        self.git(&["log", "--oneline", &format!("-{n}")])
    }

    /// List all `evo-*` tags, newest first.
    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())
    }

    /// Show a file's content as it existed at `reference`.
    pub fn show_file_at(&self, reference: &str, filepath: &str) -> Result<String> {
        self.git(&["show", &format!("{reference}:{filepath}")])
    }

    /// Create a separate working copy via `git worktree`.
    pub fn checkout_copy(&self, reference: &str, dest: &Path) -> Result<()> {
        self.git(&[
            "worktree",
            "add",
            "--detach",
            &dest.display().to_string(),
            reference,
        ])?;
        Ok(())
    }

    /// Remove a worktree created by [`checkout_copy`].
    pub fn remove_copy(&self, dest: &Path) -> Result<()> {
        self.git(&["worktree", "remove", &dest.display().to_string(), "--force"])?;
        Ok(())
    }

    // -- Internal ---------------------------------------------------------

    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);
        // No git repo — should error gracefully without panicking.
        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);
        // No git repo — should error gracefully without panicking.
        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);
        // No git repo — should error gracefully without panicking.
        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);
        // No git repo — should error gracefully without panicking.
        let result = vc.remove_copy(Path::new(&dest));
        assert!(result.is_err());
    }
}