devist 0.7.0

Project bootstrap CLI for AI-assisted development. Spin up new projects from templates, manage backends, and keep your codebase comprehensible.
#![allow(dead_code)]
// Internal git helpers reused by future worker phases.

use anyhow::{anyhow, Result};
use std::path::Path;
use std::process::Command;

/// Run a git subcommand in the given directory. Captures stdout/stderr.
pub fn run(dir: Option<&Path>, args: &[&str]) -> Result<String> {
    let mut cmd = Command::new("git");
    cmd.args(args);
    if let Some(d) = dir {
        cmd.current_dir(d);
    }

    let output = cmd
        .output()
        .map_err(|e| anyhow!("Failed to invoke git: {}", e))?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        return Err(anyhow!("git {} failed: {}", args.join(" "), stderr.trim()));
    }

    Ok(String::from_utf8_lossy(&output.stdout).to_string())
}

/// Clone a repo into target_dir. Fails if target already exists.
pub fn clone(url: &str, target_dir: &Path) -> Result<()> {
    if target_dir.exists() {
        return Err(anyhow!(
            "Target directory already exists: {}",
            target_dir.display()
        ));
    }
    let target_str = target_dir.to_string_lossy().to_string();
    run(None, &["clone", "--depth", "1", url, &target_str])?;
    Ok(())
}

/// Pull latest in an existing repo (fast-forward only).
pub fn pull(dir: &Path) -> Result<()> {
    run(Some(dir), &["pull", "--ff-only"])?;
    Ok(())
}

#[allow(dead_code)]
pub fn remote_url(dir: &Path) -> Result<String> {
    let out = run(Some(dir), &["remote", "get-url", "origin"])?;
    Ok(out.trim().to_string())
}

/// Recent commits as a list of "short_sha | date | author | subject" lines.
pub fn recent_log(dir: &Path, limit: usize) -> Result<Vec<String>> {
    let limit_str = limit.to_string();
    let out = run(
        Some(dir),
        &[
            "log",
            "--pretty=format:%h | %ad | %an | %s",
            "--date=short",
            "-n",
            &limit_str,
        ],
    )?;
    Ok(out.lines().map(|s| s.to_string()).collect())
}

/// Files changed in last N commits, with per-file change counts.
pub fn changed_files_recent(dir: &Path, last_n: usize) -> Result<Vec<(String, usize)>> {
    let limit_str = last_n.to_string();
    let out = run(
        Some(dir),
        &["log", "--name-only", "--pretty=format:", "-n", &limit_str],
    )?;

    let mut counts: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
    for line in out.lines() {
        let name = line.trim();
        if name.is_empty() {
            continue;
        }
        *counts.entry(name.to_string()).or_insert(0) += 1;
    }
    let mut v: Vec<(String, usize)> = counts.into_iter().collect();
    v.sort_by_key(|(_, count)| std::cmp::Reverse(*count));
    Ok(v)
}

/// `git status --porcelain` — current uncommitted changes.
pub fn status_porcelain(dir: &Path) -> Result<Vec<String>> {
    let out = run(Some(dir), &["status", "--porcelain"])?;
    Ok(out.lines().map(|s| s.to_string()).collect())
}

/// Current branch.
pub fn current_branch(dir: &Path) -> Result<String> {
    let out = run(Some(dir), &["rev-parse", "--abbrev-ref", "HEAD"])?;
    Ok(out.trim().to_string())
}

/// Returns true if the directory is a git repo.
pub fn is_repo(dir: &Path) -> bool {
    run(Some(dir), &["rev-parse", "--is-inside-work-tree"]).is_ok()
}