use crate::agent::cmd::{cmd_capture, cmd_run, log, origin_default_branch};
use crate::agent::issue::preflight;
use crate::agent::process::{emit_event, stop_requested};
use crate::agent::run::run_agent;
use crate::agent::tracker::{build_refresh_agents_prompt, build_refresh_docs_prompt};
use crate::agent::types::{AgentEvent, BRANCH_PREFIX, Config};
use std::collections::BTreeSet;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
fn enumerate_agent_files(cfg: &Config) -> Vec<String> {
let root_path = Path::new(&cfg.root);
let assets = crate::agent::assets::assets_dir();
let mut files = BTreeSet::new();
let agents_md = assets.join("AGENTS.md");
if agents_md.exists() {
files.insert(agents_md.to_string_lossy().to_string());
}
let skills_dir = assets.join("skills");
if skills_dir.is_dir()
&& let Ok(entries) = std::fs::read_dir(&skills_dir)
{
for entry in entries.flatten() {
if entry.path().is_dir() {
let skill_md = entry.path().join("SKILL.md");
if skill_md.exists() {
files.insert(skill_md.to_string_lossy().to_string());
}
}
}
}
for preset_skill_dir in
crate::agent::workflow::preset_skill_dirs(&cfg.root, &cfg.workflow_preset)
{
if preset_skill_dir.is_dir()
&& let Ok(entries) = std::fs::read_dir(&preset_skill_dir)
{
for entry in entries.flatten() {
if entry.path().is_dir() {
let skill_md = entry.path().join("SKILL.md");
if skill_md.exists() {
files.insert(skill_md.to_string_lossy().to_string());
}
}
}
}
}
for name in &[
"CLAUDE.md",
"CLINE.md",
"GEMINI.md",
"COPILOT.md",
"GROK.md",
"JUNIE.md",
"XAI.md",
] {
let p = root_path.join(name);
if p.exists() {
files.insert(name.to_string());
}
}
files.into_iter().collect()
}
pub fn run_refresh_agents(cfg: &Config) {
preflight(cfg);
log("Starting Refresh Agents...");
let agent_files = enumerate_agent_files(cfg);
if agent_files.is_empty() {
log("No agent-facing files found — nothing to refresh.");
emit_event(AgentEvent::Done);
return;
}
log(&format!("Found {} agent-facing file(s)", agent_files.len()));
let prompt = build_refresh_agents_prompt(&cfg.project_name, &agent_files);
if cfg.dry_run {
log("[dry-run] Would run Refresh Agents to review agent-facing docs");
log(&format!(
"[dry-run] Files in scope: {}",
agent_files.join(", ")
));
log(&format!("[dry-run] Prompt length: {} chars", prompt.len()));
emit_event(AgentEvent::Done);
return;
}
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let branch = format!("{BRANCH_PREFIX}refresh-agents-{ts}");
let trunk = origin_default_branch();
cmd_run("git", &["checkout", &trunk]);
cmd_run("git", &["branch", "-D", &branch]);
cmd_run("git", &["checkout", "-b", &branch]);
run_agent(cfg, &prompt);
if stop_requested() {
log("Stop requested. Refresh Agents cancelled.");
cmd_run("git", &["checkout", &trunk]);
emit_event(AgentEvent::Done);
return;
}
let (_, status_out) = cmd_capture("git", &["status", "--porcelain"]);
if status_out.trim().is_empty() {
log("No drift detected — agent-facing docs are up to date.");
cmd_run("git", &["checkout", &trunk]);
cmd_run("git", &["branch", "-D", &branch]);
emit_event(AgentEvent::Done);
return;
}
log("Drift detected. PR creation logic should be implemented.");
emit_event(AgentEvent::Done);
}
pub fn git_status_porcelain_scoped(cwd: Option<&Path>, paths: &[String]) -> String {
let mut args = vec!["status", "--porcelain", "--"];
args.extend(paths.iter().map(|s| s.as_str()));
let mut cmd = std::process::Command::new("git");
cmd.args(&args);
if let Some(p) = cwd {
cmd.current_dir(p);
}
let output = cmd.output().expect("failed to execute git status");
String::from_utf8_lossy(&output.stdout).to_string()
}
pub fn git_staged_files(cwd: Option<&Path>) -> Vec<String> {
let mut cmd = std::process::Command::new("git");
cmd.args(["diff", "--name-only", "--cached"]);
if let Some(p) = cwd {
cmd.current_dir(p);
}
let output = cmd.output().expect("failed to execute git diff");
String::from_utf8_lossy(&output.stdout)
.lines()
.filter(|l| !l.is_empty())
.map(|s| s.to_string())
.collect()
}
pub fn git_commit_paths(cwd: Option<&Path>, message: &str, paths: &[String]) -> bool {
let mut args = vec!["commit", "-m", message, "--"];
args.extend(paths.iter().map(|s| s.as_str()));
let mut cmd = std::process::Command::new("git");
cmd.args(&args);
if let Some(p) = cwd {
cmd.current_dir(p);
}
cmd.status().map(|s| s.success()).unwrap_or(false)
}
pub fn enumerate_project_doc_files(cfg: &Config) -> Vec<String> {
let root = Path::new(&cfg.root);
let mut files = Vec::new();
if let Ok(entries) = std::fs::read_dir(root) {
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if name.ends_with(".md") && !name.starts_with(".") {
files.push(name);
}
}
}
files
}
pub fn run_refresh_docs(cfg: &Config) {
preflight(cfg);
log("Starting Refresh Docs...");
let doc_files = enumerate_project_doc_files(cfg);
if doc_files.is_empty() {
log("No project docs found — nothing to refresh.");
emit_event(AgentEvent::Done);
return;
}
log(&format!("Found {} project doc file(s)", doc_files.len()));
let prompt = build_refresh_docs_prompt(&cfg.project_name, &doc_files);
if cfg.dry_run {
log("[dry-run] Would run Refresh Docs to review project docs");
log(&format!(
"[dry-run] Files in scope: {}",
doc_files.join(", ")
));
emit_event(AgentEvent::Done);
return;
}
run_agent(cfg, &prompt);
emit_event(AgentEvent::Done);
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn init_temp_repo() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::process::Command::new("git")
.args(["init"])
.current_dir(root)
.status()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(root)
.status()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(root)
.status()
.unwrap();
std::process::Command::new("git")
.args(["config", "commit.gpgsign", "false"])
.current_dir(root)
.status()
.unwrap();
fs::write(root.join("README.md"), "# Initial\n").unwrap();
fs::write(root.join("STATUS.md"), "# Status\n").unwrap();
std::process::Command::new("git")
.args(["add", "README.md", "STATUS.md"])
.current_dir(root)
.status()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(root)
.status()
.unwrap();
dir
}
#[test]
fn git_status_porcelain_scoped_filters_correctly() {
let dir = init_temp_repo();
let root = dir.path();
fs::write(root.join("README.md"), "edit 1\n").unwrap();
fs::write(root.join("STATUS.md"), "edit 2\n").unwrap();
let doc_files = ["README.md".to_string()];
let scoped = git_status_porcelain_scoped(Some(root), &doc_files);
assert!(scoped.contains("README.md"), "got: {scoped:?}");
assert!(!scoped.contains("STATUS.md"), "got: {scoped:?}");
}
#[test]
fn refresh_docs_commit_paths_excludes_preexisting_staged_files() {
let dir = init_temp_repo();
let root = dir.path();
fs::write(root.join("secret.env"), "TOKEN=abc\n").unwrap();
std::process::Command::new("git")
.args(["add", "secret.env"])
.current_dir(root)
.status()
.unwrap();
fs::write(root.join("README.md"), "agent edit\n").unwrap();
std::process::Command::new("git")
.args(["add", "--", "README.md"])
.current_dir(root)
.status()
.unwrap();
let doc_files = ["README.md".to_string(), "STATUS.md".to_string()];
assert!(git_commit_paths(
Some(root),
"refresh project docs",
&doc_files
));
let committed = std::process::Command::new("git")
.args(["show", "--name-only", "--pretty=format:", "HEAD"])
.current_dir(root)
.output()
.unwrap();
let committed_out = String::from_utf8_lossy(&committed.stdout);
assert!(committed_out.lines().any(|line| line == "README.md"));
assert!(!committed_out.lines().any(|line| line == "secret.env"));
assert_eq!(git_staged_files(Some(root)), vec!["secret.env".to_string()]);
}
}