//! 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)
)
}