eazygit 0.5.1

A fast TUI for Git with staging, conflicts, rebase, and palette-first UX
Documentation
//! Miscellaneous operations: pull, fetch_all, apply_patch, rebase_todo, PR, stash, config.

use anyhow::{bail, Result};
use std::process::Command;
use std::path::PathBuf;
use std::fs;
use super::GitCli;

impl GitCli {
    fn run_with_timeout(mut cmd: Command, timeout_secs: u64) -> Result<std::process::Output> {
        use std::io::Read;
        use std::time::{Duration, Instant};
        let start = Instant::now();
        let mut child = cmd.stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::piped()).spawn()?;
        loop {
            if let Some(status) = child.try_wait()? {
                let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
                if let Some(mut out) = child.stdout.take() { let _ = out.read_to_end(&mut stdout); }
                if let Some(mut err) = child.stderr.take() { let _ = err.read_to_end(&mut stderr); }
                return Ok(std::process::Output { status, stdout, stderr });
            }
            if start.elapsed() > Duration::from_secs(timeout_secs) {
                let _ = child.kill();
                let _ = child.wait();
                bail!("git command timed out after {}s", timeout_secs);
            }
            std::thread::sleep(Duration::from_millis(200));
        }
    }

    pub fn pull_ff_only(&self, repo_path: &str, ff_only: bool, timeout_secs: u64) -> Result<()> {
        let mut cmd = Command::new("git");
        cmd.args(["-C", repo_path, "pull"]);
        if ff_only { cmd.arg("--ff-only"); }
        let output = Self::run_with_timeout(cmd, timeout_secs)?;
        if !output.status.success() { bail!("git pull failed: {}", String::from_utf8_lossy(&output.stderr)); }
        Ok(())
    }

    pub fn pull_rebase(&self, repo_path: &str, autostash: bool, timeout_secs: u64) -> Result<()> {
        let mut cmd = Command::new("git");
        cmd.args(["-C", repo_path, "pull", "--rebase"]);
        if autostash { cmd.arg("--autostash"); }
        let output = Self::run_with_timeout(cmd, timeout_secs)?;
        if !output.status.success() { bail!("git pull --rebase failed: {}", String::from_utf8_lossy(&output.stderr)); }
        Ok(())
    }

    pub fn pull(&self, repo_path: &str, timeout_secs: u64) -> Result<()> {
        let output = Self::run_with_timeout(
            { let mut cmd = Command::new("git"); cmd.args(["-C", repo_path, "pull"]); cmd },
            timeout_secs
        )?;
        if !output.status.success() { bail!("git pull failed: {}", String::from_utf8_lossy(&output.stderr)); }
        Ok(())
    }

    pub fn fetch_all_prune(&self, repo_path: &str) -> Result<()> {
        let output = Command::new("git").args(["-C", repo_path, "fetch", "--all", "--prune"]).output()?;
        if !output.status.success() { bail!("git fetch --all --prune failed: {}", String::from_utf8_lossy(&output.stderr)); }
        Ok(())
    }

    pub fn apply_patch(&self, repo_path: &str, patch: &str, to_index: bool, reverse: bool) -> Result<()> {
        use std::io::Write;
        use std::process::Stdio;
        let mut cmd = Command::new("git");
        cmd.args(["-C", repo_path, "apply"]);
        if to_index { cmd.arg("--cached"); }
        if reverse { cmd.arg("--reverse"); }
        let mut child = cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn()?;
        if let Some(mut stdin) = child.stdin.take() { stdin.write_all(patch.as_bytes())?; }
        let output = child.wait_with_output()?;
        if !output.status.success() { bail!("git apply failed: {}", String::from_utf8_lossy(&output.stderr)); }
        Ok(())
    }

    pub fn read_rebase_todo(&self, repo_path: &str) -> Result<(Vec<String>, String)> {
        let candidates = [
            PathBuf::from(repo_path).join(".git").join("rebase-merge").join("git-rebase-todo"),
            PathBuf::from(repo_path).join(".git").join("rebase-apply").join("git-rebase-todo"),
        ];
        for path in &candidates {
            if path.exists() {
                let lines: Vec<String> = fs::read_to_string(path)?.lines().map(|s| s.to_string()).collect();
                return Ok((lines, path.to_string_lossy().to_string()));
            }
        }
        bail!("no rebase todo found");
    }

    pub fn start_rebase_interactive(&self, repo_path: &str, base: &str) -> Result<String> {
        let git_dir = PathBuf::from(repo_path).join(".git");
        let rebase_merge = git_dir.join("rebase-merge");
        let rebase_apply = git_dir.join("rebase-apply");
        if rebase_merge.exists() { fs::remove_dir_all(&rebase_merge).ok(); }
        if rebase_apply.exists() { fs::remove_dir_all(&rebase_apply).ok(); }
        let marker = std::env::temp_dir().join(format!("eazygit_todo_{}", std::process::id()));
        let script = format!("cp \"$1\" \"{}\" && printf 'break\\n' > \"$1\"", marker.to_string_lossy());
        let output = Command::new("git")
            .env("GIT_SEQUENCE_EDITOR", format!("sh -c '{}' sh", script))
            .args(["-C", repo_path, "rebase", "-i", base]).output()?;
        if !marker.exists() { bail!("git rebase -i {} failed: {}", base, String::from_utf8_lossy(&output.stderr).lines().next().unwrap_or("")); }
        let _ = fs::remove_file(&marker);
        for dir in &[&rebase_merge, &rebase_apply] {
            let todo = dir.join("git-rebase-todo");
            if todo.exists() { return Ok(todo.to_string_lossy().to_string()); }
        }
        bail!("git rebase -i {} did not create todo file", base);
    }

    pub fn start_rebase_interactive_root(&self, repo_path: &str) -> Result<String> {
        let git_dir = PathBuf::from(repo_path).join(".git");
        let rebase_merge = git_dir.join("rebase-merge");
        let rebase_apply = git_dir.join("rebase-apply");
        if rebase_merge.exists() { fs::remove_dir_all(&rebase_merge).ok(); }
        if rebase_apply.exists() { fs::remove_dir_all(&rebase_apply).ok(); }
        let marker = std::env::temp_dir().join(format!("eazygit_todo_root_{}", std::process::id()));
        let script = format!("cp \"$1\" \"{}\" && printf 'break\\n' > \"$1\"", marker.to_string_lossy());
        let output = Command::new("git")
            .env("GIT_SEQUENCE_EDITOR", format!("sh -c '{}' sh", script))
            .args(["-C", repo_path, "rebase", "-i", "--root"]).output()?;
        if !marker.exists() { bail!("git rebase -i --root failed: {}", String::from_utf8_lossy(&output.stderr).lines().next().unwrap_or("")); }
        let _ = fs::remove_file(&marker);
        for dir in &[&rebase_merge, &rebase_apply] {
            let todo = dir.join("git-rebase-todo");
            if todo.exists() { return Ok(todo.to_string_lossy().to_string()); }
        }
        bail!("git rebase -i --root did not create todo file");
    }

    /// Start rebase with a custom todo file
    /// Uses GIT_SEQUENCE_EDITOR to inject the todo file content
    pub fn start_rebase_with_todo(&self, repo_path: &str, base: &str, todo_content: &str, use_root: bool) -> Result<String> {
        let git_dir = PathBuf::from(repo_path).join(".git");
        let rebase_merge = git_dir.join("rebase-merge");
        let rebase_apply = git_dir.join("rebase-apply");
        
        // Clean up any existing rebase
        if rebase_merge.exists() { fs::remove_dir_all(&rebase_merge).ok(); }
        if rebase_apply.exists() { fs::remove_dir_all(&rebase_apply).ok(); }
        
        // Create temporary todo file
        let temp_todo = std::env::temp_dir().join(format!("eazygit_rebase_todo_{}_{}", std::process::id(), std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()));
        fs::write(&temp_todo, todo_content)?;
        
        // Use GIT_SEQUENCE_EDITOR to copy our todo file
        let script = format!("cp \"{}\" \"$1\"", temp_todo.to_string_lossy());
        let rebase_args = if use_root {
            vec!["-C", repo_path, "rebase", "-i", "--root"]
        } else {
            vec!["-C", repo_path, "rebase", "-i", base]
        };
        
        let output = Command::new("git")
            .env("GIT_SEQUENCE_EDITOR", format!("sh -c '{}' sh", script))
            .args(&rebase_args)
            .output()?;
        
        // Clean up temp file
        let _ = fs::remove_file(&temp_todo);
        
        if !output.status.success() {
            bail!("git rebase -i {} failed: {}", base, String::from_utf8_lossy(&output.stderr));
        }
        
        // Find the created todo file
        for dir in &[&rebase_merge, &rebase_apply] {
            let todo = dir.join("git-rebase-todo");
            if todo.exists() { return Ok(todo.to_string_lossy().to_string()); }
        }
        bail!("git rebase -i {} did not create todo file", base);
    }

    pub fn root_commit(&self, repo_path: &str) -> Result<String> {
        let output = Command::new("git").args(["-C", repo_path, "rev-list", "--max-parents=0", "HEAD"]).output()?;
        if !output.status.success() { bail!("git rev-list failed: {}", String::from_utf8_lossy(&output.stderr)); }
        Ok(String::from_utf8_lossy(&output.stdout).lines().next().unwrap_or("").to_string())
    }

    pub fn list_pull_requests(&self, repo_path: &str, remote: &str) -> Result<Vec<(String, String)>> {
        let output = Command::new("git").args(["-C", repo_path, "ls-remote", "--refs", remote, "pull/*/head"]).output()?;
        if !output.status.success() { bail!("git ls-remote failed: {}", String::from_utf8_lossy(&output.stderr)); }
        let mut prs = Vec::new();
        for line in String::from_utf8_lossy(&output.stdout).lines() {
            let parts: Vec<&str> = line.split_whitespace().collect();
            if parts.len() >= 2 {
                if let Some(num) = parts.get(1).and_then(|r| r.trim().split('/').nth(2)) {
                    prs.push((num.to_string(), parts[1].to_string()));
                }
            }
        }
        Ok(prs)
    }

    pub fn fetch_pr(&self, repo_path: &str, remote: &str, pr: &str) -> Result<()> {
        let target = format!("pull/{}/head:pr/{}", pr, pr);
        let output = Command::new("git").args(["-C", repo_path, "fetch", remote, &target]).output()?;
        if !output.status.success() { bail!("git fetch {} failed: {}", target, String::from_utf8_lossy(&output.stderr)); }
        Ok(())
    }

    pub fn checkout_pr_branch(&self, repo_path: &str, pr: &str) -> Result<()> {
        let branch = format!("pr/{}", pr);
        let output = Command::new("git").args(["-C", repo_path, "checkout", &branch]).output()?;
        if !output.status.success() { bail!("git checkout {} failed: {}", branch, String::from_utf8_lossy(&output.stderr)); }
        Ok(())
    }

    pub fn get_config(&self, repo_path: &str, key: &str) -> Result<Option<String>> {
        let output = Command::new("git").args(["-C", repo_path, "config", "--get", key]).output()?;
        if output.status.success() {
            Ok(Some(String::from_utf8_lossy(&output.stdout).trim().to_string()))
        } else {
            Ok(None)
        }
    }
}