bxnet-ops/org-ops-core/src/git_commands.rs
Tyler J King 92464b07c5 refactor(git_commands): migrate Chronicle emission to CloudEvents
Replace fake Forgejo push webhook construction with structured
CloudEvents 1.0 via ChronicleClient. Git commit SHAs are now used
as CloudEvent ids for COMMIT_CREATED and PUSH events, enabling
direct correlation between Chronicle entries and git history.

Event renames:
- REPO_CLONED -> GOV_REPO_CLONED
- COMMIT_CREATED -> GOV_COMMIT_CREATED
- GOVERNED_PUSH -> GOV_PUSH
- PR_CREATED -> GOV_PR_CREATED

Signed-off-by: Tyler King <tking@guildhouse.dev>
2026-04-12 06:51:42 -04:00

425 lines
15 KiB
Rust

//! 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<String>,
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<String> {
let (out, _, _) = Self::git(&["diff", "--name-only", "HEAD"]);
let mut files: Vec<String> = 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<String> {
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::<Vec<_>>()
.into_iter()
.rev()
.collect::<Vec<_>>()
.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<clap::Command> {
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::<String>("repo").unwrap();
self.cmd_clone(repo, ctx)
}
Some(("add", sub)) => {
let files: Vec<&str> = sub
.get_many::<String>("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::<String>("message").unwrap();
self.cmd_commit(msg, ctx)
}
Some(("push", sub)) => {
let remote = sub.get_one::<String>("remote").map(|s| s.as_str()).unwrap_or("origin");
let branch = sub.get_one::<String>("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::<String>("title").unwrap();
self.cmd_pr_create(title, ctx)
}
_ => {
println!("Usage: git pr create -t 'title'");
Ok(())
}
},
_ => {
println!("Usage: guildhouse-ops git <clone|status|add|commit|push|pr>");
Ok(())
}
}
}
}