//! Governed git subcommands. //! //! Wraps git operations with accord validation, corpus score checks, //! and Chronicle attribution. use crate::chronicle_client::ChronicleClient; use crate::session::SessionContext; use crate::traits::OrgCommands; use std::process::Command; pub struct GitConfig { pub forgejo_url: String, pub forgejo_token: Option, pub chronicle_webhook: String, } impl Default for GitConfig { fn default() -> Self { Self { forgejo_url: "https://git.bxnet.io".into(), forgejo_token: None, chronicle_webhook: "http://localhost:8090/webhook/forgejo".into(), } } } pub struct GovernedGitCommands { config: GitConfig, } impl GovernedGitCommands { pub fn new(config: GitConfig) -> Self { Self { config } } fn git(args: &[&str]) -> (String, String, i32) { let output = Command::new("git") .args(args) .output() .expect("git not found in PATH"); ( String::from_utf8_lossy(&output.stdout).to_string(), String::from_utf8_lossy(&output.stderr).to_string(), output.status.code().unwrap_or(-1), ) } fn corpus_score(entry_name: &str) -> Option<(u8, String, bool)> { let output = Command::new("kubectl") .args([ "get", "corpusentry", entry_name, "-o", "jsonpath={.status.riskScore.composite}|{.status.riskScore.capabilityCeiling}|{.status.riskScore.bomTriadComplete}", ]) .output() .ok()?; if !output.status.success() { return None; } let s = String::from_utf8_lossy(&output.stdout); let parts: Vec<&str> = s.trim().split('|').collect(); Some(( parts.first().and_then(|v| v.parse().ok()).unwrap_or(0), parts.get(1).unwrap_or(&"CAP_READ").to_string(), parts.get(2).map(|v| *v == "true").unwrap_or(false), )) } fn changed_files() -> Vec { let (out, _, _) = Self::git(&["diff", "--name-only", "HEAD"]); let mut files: Vec = out.lines().map(|s| s.to_string()).collect(); // Also include staged but not yet committed let (staged, _, _) = Self::git(&["diff", "--cached", "--name-only"]); for f in staged.lines() { if !files.contains(&f.to_string()) { files.push(f.to_string()); } } files } fn infer_corpus_entry(path: &str) -> Option { std::path::Path::new(path) .file_stem() .and_then(|s| s.to_str()) .map(|s| s.to_string()) } fn chronicle(&self) -> ChronicleClient { ChronicleClient::from_legacy_webhook(&self.config.chronicle_webhook) } fn cmd_status(&self, _ctx: &SessionContext) -> anyhow::Result<()> { let (out, _, _) = Self::git(&["status", "--short"]); if out.is_empty() { println!("Nothing to commit, working tree clean."); return Ok(()); } println!("{}", out.trim()); // Governance overlay let changed = Self::changed_files(); if changed.is_empty() { return Ok(()); } println!("\n-- Governance Impact --"); for file in &changed { if let Some(entry) = Self::infer_corpus_entry(file) { if let Some((score, ceiling, triad)) = Self::corpus_score(&entry) { let triad_mark = if triad { "Y" } else { "N" }; println!( " {} -> corpus:{} score={}/100 {} triad={}", file, entry, score, ceiling, triad_mark ); } else { println!(" {} (no corpus entry)", file); } } else { println!(" {}", file); } } println!("--"); Ok(()) } fn cmd_clone(&self, repo: &str, ctx: &SessionContext) -> anyhow::Result<()> { let url = if repo.contains("://") { repo.to_string() } else { format!("{}/{}.git", self.config.forgejo_url, repo) }; println!("Cloning {}...", url); let (out, err, rc) = Self::git(&["clone", &url]); if rc != 0 { anyhow::bail!("git clone failed: {}", err); } if !out.is_empty() { println!("{}", out.trim()); } let actor_did = format!("did:web:{}/user/operator", ctx.trust_domain); self.chronicle().emit( "GOV_REPO_CLONED", &actor_did, &ChronicleClient::generate_id(), serde_json::json!({ "kind": "GOV_REPO_CLONED", "description": format!("repo={}", repo), "repo": repo, }), ); println!("Cloned. Chronicle: GOV_REPO_CLONED"); Ok(()) } fn cmd_commit(&self, message: &str, _ctx: &SessionContext) -> anyhow::Result<()> { // Pre-commit checks let changed = Self::changed_files(); let yaml_files: Vec<&String> = changed .iter() .filter(|f| f.ends_with(".yml") || f.ends_with(".yaml")) .collect(); // Basic secret scan for f in &yaml_files { if let Ok(content) = std::fs::read_to_string(f) { let lc = content.to_lowercase(); for pat in &["password:", "secret:", "api_key:", "private_key:"] { if lc.contains(pat) { eprintln!(" [secrets] {}: possible secret ({}) — review", f, pat); } } } } let (out, err, rc) = Self::git(&["commit", "-m", message]); if rc != 0 { anyhow::bail!("git commit failed: {}", err); } println!("{}", out.trim()); Ok(()) } fn cmd_push( &self, remote: &str, branch: &str, ctx: &SessionContext, ) -> anyhow::Result<()> { println!("-- Pre-push governance checks --"); // Corpus score check for changed files let changed = Self::changed_files(); let mut blocked = false; for file in &changed { if let Some(entry) = Self::infer_corpus_entry(file) { if let Some((score, ceiling, _)) = Self::corpus_score(&entry) { print!(" corpus [{}]: {}/100 {}", entry, score, ceiling); if branch == "main" && score < 70 { println!(" BLOCKED (main requires >= 70)"); blocked = true; } else { println!(" OK"); } } } } if blocked { anyhow::bail!("Push blocked by governance checks."); } // Governance summary let (log, _, _) = Self::git(&["log", "--oneline", "-3"]); println!("\n Commits:"); for line in log.lines() { println!(" {}", line); } println!(" Target: {}/{}", remote, branch); println!(" Actor: did:web:{}/user/operator", ctx.trust_domain); println!("--"); // Emit GOV_COMMIT_CREATED for each commit in the push range let actor_did = format!("did:web:{}/user/operator", ctx.trust_domain); let chronicle = self.chronicle(); let (commit_log, _, _) = Self::git(&["log", &format!("{}/{}..HEAD", remote, branch), "--format=%H|%s", "--no-merges"]); for line in commit_log.lines().filter(|l| !l.is_empty()) { let parts: Vec<&str> = line.splitn(2, '|').collect(); if parts.len() >= 2 { let commit_sha = parts[0].trim(); chronicle.emit( "GOV_COMMIT_CREATED", &actor_did, commit_sha, serde_json::json!({ "kind": "GOV_COMMIT_CREATED", "description": format!("sha={} msg={}", commit_sha, parts[1]), "git_commit": commit_sha, "message": parts[1], "git_ref": format!("refs/heads/{}", branch), }), ); } } // Chronicle: GOV_PUSH let (sha, _, _) = Self::git(&["rev-parse", "HEAD"]); let head_sha = sha.trim(); chronicle.emit( "GOV_PUSH", &actor_did, head_sha, serde_json::json!({ "kind": "GOV_PUSH", "description": format!("{}@{} -> {}/{}", head_sha, branch, remote, branch), "git_commit": head_sha, "git_ref": format!("refs/heads/{}", branch), "remote": remote, "branch": branch, }), ); // Actual push let (out, err, rc) = Self::git(&["push", remote, branch]); if rc != 0 { anyhow::bail!("git push failed: {}", err); } if !out.is_empty() { println!("{}", out.trim()); } println!("Chronicle: GOV_PUSH recorded"); Ok(()) } fn cmd_pr_create(&self, title: &str, ctx: &SessionContext) -> anyhow::Result<()> { let (branch, _, _) = Self::git(&["branch", "--show-current"]); let branch = branch.trim(); let (remote_url, _, _) = Self::git(&["remote", "get-url", "origin"]); let repo = remote_url .trim() .trim_end_matches(".git") .rsplit('/') .take(2) .collect::>() .into_iter() .rev() .collect::>() .join("/"); let url = format!("{}/api/v1/repos/{}/pulls", self.config.forgejo_url, repo); let mut req = reqwest::blocking::Client::new().post(&url).json(&serde_json::json!({ "title": title, "head": branch, "base": "main", "body": format!( "## Governance\n\nActor: did:web:{}/user/operator\n\n*Created via guildhouse-ops git pr create*", ctx.trust_domain ) })); if let Some(ref tok) = self.config.forgejo_token { req = req.bearer_auth(tok); } match req.send() { Ok(resp) if resp.status().is_success() => { let data: serde_json::Value = resp.json().unwrap_or_default(); let pr_url = data["html_url"].as_str().unwrap_or("?"); println!("PR created: {}", pr_url); let actor_did = format!("did:web:{}/user/operator", ctx.trust_domain); let (sha, _, _) = Self::git(&["rev-parse", "HEAD"]); self.chronicle().emit( "GOV_PR_CREATED", &actor_did, sha.trim(), serde_json::json!({ "kind": "GOV_PR_CREATED", "description": format!("PR: {} ({})", title, pr_url), "git_commit": sha.trim(), "branch": branch, "pr_url": pr_url, "title": title, }), ); } Ok(resp) => eprintln!("PR creation failed: {}", resp.status()), Err(e) => eprintln!("PR creation error: {}", e), } Ok(()) } } impl OrgCommands for GovernedGitCommands { fn commands(&self) -> Vec { use clap::{Arg, Command}; vec![Command::new("git") .about("Governed git operations with Chronicle attribution") .subcommand( Command::new("clone") .about("Clone a governed repository") .arg(Arg::new("repo").required(true)), ) .subcommand(Command::new("status").about("git status with governance overlay")) .subcommand( Command::new("add") .about("Stage files") .arg(Arg::new("files").num_args(1..)), ) .subcommand( Command::new("commit") .about("Commit with pre-commit checks") .arg(Arg::new("message").short('m').required(true)), ) .subcommand( Command::new("push") .about("Governed push: corpus check -> Chronicle -> push") .arg(Arg::new("remote").default_value("origin")) .arg(Arg::new("branch").default_value("main")), ) .subcommand( Command::new("pr").about("PR governance").subcommand( Command::new("create") .about("Create governed PR") .arg(Arg::new("title").short('t').required(true)), ), )] } fn handles(&self, name: &str) -> bool { name == "git" } fn handle( &self, _name: &str, matches: &clap::ArgMatches, ctx: &SessionContext, ) -> anyhow::Result<()> { match matches.subcommand() { Some(("status", _)) => self.cmd_status(ctx), Some(("clone", sub)) => { let repo = sub.get_one::("repo").unwrap(); self.cmd_clone(repo, ctx) } Some(("add", sub)) => { let files: Vec<&str> = sub .get_many::("files") .unwrap_or_default() .map(|s| s.as_str()) .collect(); let mut args = vec!["add"]; args.extend(files); let (_, err, rc) = Self::git(&args); if rc != 0 { anyhow::bail!(err); } Ok(()) } Some(("commit", sub)) => { let msg = sub.get_one::("message").unwrap(); self.cmd_commit(msg, ctx) } Some(("push", sub)) => { let remote = sub.get_one::("remote").map(|s| s.as_str()).unwrap_or("origin"); let branch = sub.get_one::("branch").map(|s| s.as_str()).unwrap_or("main"); self.cmd_push(remote, branch, ctx) } Some(("pr", sub)) => match sub.subcommand() { Some(("create", s)) => { let title = s.get_one::("title").unwrap(); self.cmd_pr_create(title, ctx) } _ => { println!("Usage: git pr create -t 'title'"); Ok(()) } }, _ => { println!("Usage: guildhouse-ops git "); Ok(()) } } } }