diff --git a/crates/arc-workflows/src/cli/run.rs b/crates/arc-workflows/src/cli/run.rs index f59d834ee..3bae083ad 100644 --- a/crates/arc-workflows/src/cli/run.rs +++ b/crates/arc-workflows/src/cli/run.rs @@ -242,9 +242,25 @@ pub async fn run_command( Some(vars) => run_config::expand_vars(&source, vars)?, None => source, }; - let (mut graph, diagnostics) = WorkflowBuilder::new().prepare(&source)?; + let dot_dir = dot_path.parent().unwrap_or(std::path::Path::new(".")); + let mut builder = WorkflowBuilder::new(); + builder.register_transform(Box::new( + crate::transform::FileInliningTransform::new(dot_dir.to_path_buf()), + )); + let (mut graph, diagnostics) = builder.prepare(&source)?; apply_goal_override(&mut graph, args.goal.as_deref()); + // Inline @file references in the (possibly overridden) goal + if let Some(crate::graph::types::AttrValue::String(goal)) = graph.attrs.get("goal") { + let resolved = crate::transform::resolve_file_ref(goal, dot_dir); + if resolved != *goal { + graph.attrs.insert( + "goal".to_string(), + crate::graph::types::AttrValue::String(resolved), + ); + } + } + eprintln!( "{} {} {}", styles.bold.apply_to("Workflow:"), diff --git a/crates/arc-workflows/src/git.rs b/crates/arc-workflows/src/git.rs index a0a546b1f..49bd922f1 100644 --- a/crates/arc-workflows/src/git.rs +++ b/crates/arc-workflows/src/git.rs @@ -291,6 +291,18 @@ pub fn push_ref(repo: &Path, url: &str, refname: &str) -> Result<()> { Ok(()) } +/// Check whether a file is tracked by git in the given repo. +/// Returns `false` if the file is untracked or git is unavailable. +pub fn is_tracked(repo: &Path, file: &Path) -> bool { + git_cmd(repo) + .args(["ls-files", "--error-unmatch"]) + .arg(file) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .is_ok_and(|s| s.success()) +} + /// Sanitize a string for use as a git ref component. /// Lowercases, replaces non-alphanumeric chars with dashes, collapses runs. pub fn sanitize_ref_component(s: &str) -> String { @@ -1100,4 +1112,44 @@ mod tests { let stdout = String::from_utf8_lossy(&output.stdout); assert!(stdout.contains("test-push"), "remote should have test-push branch"); } + + #[test] + fn is_tracked_returns_true_for_committed_file() { + let dir = tempfile::tempdir().unwrap(); + init_repo(dir.path()); + let file = dir.path().join("tracked.txt"); + fs::write(&file, "hello").unwrap(); + Command::new("git") + .args(["add", "tracked.txt"]) + .current_dir(dir.path()) + .output() + .unwrap(); + Command::new("git") + .args([ + "-c", "user.name=test", + "-c", "user.email=test@test", + "commit", "-m", "add file", + ]) + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(is_tracked(dir.path(), &file)); + } + + #[test] + fn is_tracked_returns_false_for_untracked_file() { + let dir = tempfile::tempdir().unwrap(); + init_repo(dir.path()); + let file = dir.path().join("untracked.txt"); + fs::write(&file, "hello").unwrap(); + assert!(!is_tracked(dir.path(), &file)); + } + + #[test] + fn is_tracked_returns_false_for_non_repo_dir() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("some.txt"); + fs::write(&file, "hello").unwrap(); + assert!(!is_tracked(dir.path(), &file)); + } } diff --git a/crates/arc-workflows/src/transform.rs b/crates/arc-workflows/src/transform.rs index 3c52d6d49..d60d2e1ea 100644 --- a/crates/arc-workflows/src/transform.rs +++ b/crates/arc-workflows/src/transform.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::path::{Path, PathBuf}; use crate::graph::{AttrValue, Edge, Graph, Node}; use crate::stylesheet::{apply_stylesheet, parse_stylesheet}; @@ -111,6 +112,83 @@ impl Transform for StylesheetApplicationTransform { } } +/// Resolve a potential `@path` file reference. +/// +/// If `value` starts with `@`, the referenced file exists locally, and is NOT +/// tracked by git, the file contents are returned (inlined). Otherwise the +/// original value is returned unchanged. +pub fn resolve_file_ref(value: &str, base_dir: &Path) -> String { + let path_str = match value.strip_prefix('@') { + Some(p) => p, + None => return value.to_string(), + }; + + let file_path = base_dir.join(path_str); + if !file_path.is_file() { + return value.to_string(); + } + + // Discover repo root from base_dir + let repo_root = std::process::Command::new("git") + .args(["rev-parse", "--show-toplevel"]) + .current_dir(base_dir) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| PathBuf::from(String::from_utf8_lossy(&o.stdout).trim().to_string())); + + if let Some(root) = repo_root { + if crate::git::is_tracked(&root, &file_path) { + return value.to_string(); + } + } + + match std::fs::read_to_string(&file_path) { + Ok(contents) => contents, + Err(e) => { + tracing::warn!(path = %file_path.display(), error = %e, "Failed to read @file reference"); + value.to_string() + } + } +} + +/// Inlines untracked `@file` references in node prompts and the graph-level goal. +pub struct FileInliningTransform { + base_dir: PathBuf, +} + +impl FileInliningTransform { + #[must_use] + pub fn new(base_dir: PathBuf) -> Self { + Self { base_dir } + } +} + +impl Transform for FileInliningTransform { + fn apply(&self, graph: &mut Graph) { + // Inline @file refs in node prompts + for node in graph.nodes.values_mut() { + if let Some(AttrValue::String(prompt)) = node.attrs.get("prompt") { + let resolved = resolve_file_ref(prompt, &self.base_dir); + if resolved != *prompt { + node.attrs + .insert("prompt".to_string(), AttrValue::String(resolved)); + } + } + } + + // Inline @file refs in graph-level goal + if let Some(AttrValue::String(goal)) = graph.attrs.get("goal") { + let resolved = resolve_file_ref(goal, &self.base_dir); + if resolved != *goal { + graph + .attrs + .insert("goal".to_string(), AttrValue::String(resolved)); + } + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -516,4 +594,179 @@ mod tests { Some("outcome=success") ); } + + // ----------------------------------------------------------------------- + // resolve_file_ref tests + // ----------------------------------------------------------------------- + + #[test] + fn resolve_file_ref_passthrough_non_at() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(resolve_file_ref("hello world", dir.path()), "hello world"); + } + + #[test] + fn resolve_file_ref_passthrough_missing_file() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!( + resolve_file_ref("@nonexistent.md", dir.path()), + "@nonexistent.md" + ); + } + + #[test] + fn resolve_file_ref_passthrough_tracked_file() { + let dir = tempfile::tempdir().unwrap(); + // Init repo and commit a file + std::process::Command::new("git") + .args(["init"]) + .current_dir(dir.path()) + .output() + .unwrap(); + std::fs::write(dir.path().join("tracked.md"), "tracked content").unwrap(); + std::process::Command::new("git") + .args(["add", "tracked.md"]) + .current_dir(dir.path()) + .output() + .unwrap(); + std::process::Command::new("git") + .args([ + "-c", "user.name=test", + "-c", "user.email=test@test", + "commit", "-m", "add", + ]) + .current_dir(dir.path()) + .output() + .unwrap(); + + assert_eq!( + resolve_file_ref("@tracked.md", dir.path()), + "@tracked.md" + ); + } + + #[test] + fn resolve_file_ref_inlines_untracked_file() { + let dir = tempfile::tempdir().unwrap(); + // Init repo + std::process::Command::new("git") + .args(["init"]) + .current_dir(dir.path()) + .output() + .unwrap(); + std::process::Command::new("git") + .args([ + "-c", "user.name=test", + "-c", "user.email=test@test", + "commit", "--allow-empty", "-m", "init", + ]) + .current_dir(dir.path()) + .output() + .unwrap(); + + std::fs::write(dir.path().join("local.md"), "inlined content").unwrap(); + + assert_eq!( + resolve_file_ref("@local.md", dir.path()), + "inlined content" + ); + } + + // ----------------------------------------------------------------------- + // FileInliningTransform tests + // ----------------------------------------------------------------------- + + #[test] + fn file_inlining_transform_inlines_prompt_and_goal() { + let dir = tempfile::tempdir().unwrap(); + // Init repo + std::process::Command::new("git") + .args(["init"]) + .current_dir(dir.path()) + .output() + .unwrap(); + std::process::Command::new("git") + .args([ + "-c", "user.name=test", + "-c", "user.email=test@test", + "commit", "--allow-empty", "-m", "init", + ]) + .current_dir(dir.path()) + .output() + .unwrap(); + + std::fs::write(dir.path().join("prompt.md"), "Do the work").unwrap(); + std::fs::write(dir.path().join("goal.md"), "Ship feature").unwrap(); + + let mut graph = Graph::new("test"); + graph.attrs.insert( + "goal".to_string(), + AttrValue::String("@goal.md".to_string()), + ); + let mut node = Node::new("work"); + node.attrs.insert( + "prompt".to_string(), + AttrValue::String("@prompt.md".to_string()), + ); + graph.nodes.insert("work".to_string(), node); + + let transform = FileInliningTransform::new(dir.path().to_path_buf()); + transform.apply(&mut graph); + + assert_eq!( + graph.nodes["work"] + .attrs + .get("prompt") + .and_then(AttrValue::as_str), + Some("Do the work") + ); + assert_eq!( + graph.attrs.get("goal").and_then(AttrValue::as_str), + Some("Ship feature") + ); + } + + #[test] + fn file_inlining_transform_leaves_tracked_files() { + let dir = tempfile::tempdir().unwrap(); + std::process::Command::new("git") + .args(["init"]) + .current_dir(dir.path()) + .output() + .unwrap(); + std::fs::write(dir.path().join("prompt.md"), "committed content").unwrap(); + std::process::Command::new("git") + .args(["add", "prompt.md"]) + .current_dir(dir.path()) + .output() + .unwrap(); + std::process::Command::new("git") + .args([ + "-c", "user.name=test", + "-c", "user.email=test@test", + "commit", "-m", "add", + ]) + .current_dir(dir.path()) + .output() + .unwrap(); + + let mut graph = Graph::new("test"); + let mut node = Node::new("work"); + node.attrs.insert( + "prompt".to_string(), + AttrValue::String("@prompt.md".to_string()), + ); + graph.nodes.insert("work".to_string(), node); + + let transform = FileInliningTransform::new(dir.path().to_path_buf()); + transform.apply(&mut graph); + + assert_eq!( + graph.nodes["work"] + .attrs + .get("prompt") + .and_then(AttrValue::as_str), + Some("@prompt.md") + ); + } }