swarm / office/src/gitrepo.rs

raw file 5654 bytes
//! Tiny git layer: the product repo becomes a real version-controlled
//! project — every work action is a commit by its author, reviews see
//! diffs, the boss can revert, and the commit log is a readable transcript
//! of the sim.
//!
//! All commands run with inline `-c user.name/-c user.email` so nothing
//! touches the user's global git config. Every helper degrades gracefully
//! when git is absent or the repo is bare.

use std::path::Path;

fn run_git(repo: &Path, identity: Option<(&str, &str)>, args: &[&str]) -> Option<String> {
    // Resolve once: a relative repo path gets double-resolved by the child's
    // chdir + -C combo otherwise (fatal: cannot change to '<rel>').
    let repo_abs = std::fs::canonicalize(repo).unwrap_or_else(|_| repo.to_path_buf());
    let mut cmd = std::process::Command::new("git");
    cmd.current_dir(&repo_abs).arg("-C").arg(&repo_abs);
    if let Some((name, email)) = identity {
        cmd.arg("-c").arg(format!("user.name={name}"));
        cmd.arg("-c").arg(format!("user.email={email}"));
    }
    cmd.args(args);
    let out = cmd.output().ok()?;
    let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
    let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
    if out.status.success() {
        Some(stdout)
    } else {
        // Surface the actual reason (e.g. "nothing to commit").
        Some(format!("(git {}) {}", args.join(" "), stderr.lines().next().unwrap_or("").trim()))
    }
}

/// One-time: make `work/project/` a git repo and commit the seed scaffold.
pub fn init(repo: &Path) {
    if !which_git() {
        eprintln!("[git] git is not on PATH — repo history disabled");
        return;
    }
    let out = run_git(repo, Some(("system", "system@dojo")), &["init", "-q"]).unwrap_or_default();
    if out.contains("fatal") || out.contains("error") || !repo.join(".git").exists() {
        eprintln!("[git] init said {} (has .git: {})", out.trim(), repo.join(".git").exists());
    }
    run_git(repo, Some(("system", "system@dojo")), &["init", "-q"]);
    // Generated reports churn every round — keep them out of history.
    let ignore = repo.join(".gitignore");
    if !ignore.exists() {
        std::fs::write(&ignore, "judge_report.md\nverify_report.md\n").ok();
    }
    run_git(
        repo,
        Some(("system", "system@dojo")),
        &["add", "-A", "--", "."],
    );
    run_git(
        repo,
        Some(("system", "system@dojo")),
        &["commit", "-q", "-m", "seed: day-1 scaffold (harness + contract)", "--allow-empty"],
    );
}

fn which_git() -> bool {
    std::process::Command::new("git")
        .arg("--version")
        .output()
        .map(|o| o.status.success())
        .unwrap_or(false)
}

/// Commit exactly `paths` under `repo` as `author`. Returns Some(text) when a
/// commit actually landed, None when there was nothing new (or git missing).
pub fn commit(
    repo: &Path,
    author: &str,
    email: &str,
    message: &str,
    paths: &[String],
) -> Option<String> {
    if !which_git() || paths.is_empty() {
        return None;
    }
    let mut add_args: Vec<String> = vec!["add".into()];
    add_args.extend(paths.iter().cloned());
    let add_ref: Vec<&str> = add_args.iter().map(String::as_str).collect();
    run_git(repo, Some(("system", "system@dojo")), &add_ref)?;

    // Avoid empty commits: check if the index has anything staged above HEAD.
    let status = run_git(repo, None, &["diff", "--cached", "--name-only"])?;
    if status.trim().is_empty() {
        return None;
    }
    let msg: String = message.chars().take(110).collect();
    run_git(
        repo,
        Some((author, email)),
        &["commit", "-q", "-m", &msg],
    )
}

/// `git diff HEAD~1 HEAD --stat` + truncated patch — what a reviewer sees.
pub fn show_last_diff(repo: &Path, max_chars: usize) -> Option<String> {
    if !which_git() {
        return None;
    }
    let stat = run_git(repo, None, &["diff", "HEAD~1", "HEAD", "--stat"])?;
    let patch = run_git(repo, None, &["diff", "HEAD~1", "HEAD"])?;
    let patch: String = patch.chars().take(max_chars).collect();
    let text = format!("{stat}\n\n{patch}");
    if text.trim().is_empty() {
        None
    } else {
        Some(text)
    }
}

/// Recent commits for the panel/TUI: "hash  author  message".
pub fn log(repo: &Path, limit: usize) -> Option<Vec<String>> {
    if !which_git() {
        return None;
    }
    let n = limit.to_string();
    let fmt = "--pretty=format:%h %an › %s";
    run_git(
        repo,
        None,
        &["log", &format!("-{n}"), fmt],
    )
    .map(|out| out.lines().map(str::to_string).collect())
}

/// Full detail for one commit hash (TUI preview / panel).
pub fn show(repo: &Path, hash: &str) -> Option<String> {
    if !which_git() {
        return None;
    }
    run_git(repo, None, &["show", "--stat", "--format=short", hash])
}

/// Boss convenience: revert the most recent commit (leaves a real scar).
pub fn revert_head(repo: &Path, by: &str) -> Option<String> {
    if !which_git() {
        return None;
    }
    run_git(
        repo,
        Some((by, format!("{}@dojo", slug_of(by)).as_str())),
        &[
            "revert",
            "--no-edit",
            "-q",
            "HEAD",
        ],
    )
}

fn slug_of(name: &str) -> String {
    name.to_lowercase()
        .chars()
        .map(|c| if c.is_alphanumeric() { c } else { '-' })
        .collect()
}

/// Author email derived from the person slug.
pub fn person_email(person: &str, company: &str) -> String {
    format!(
        "{}@{}",
        slug_of(&person.replace(' ', "")),
        slug_of(company)
    )
}