mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-07 08:27:12 +00:00
Inline untracked @file references in DOT prompts at prepare time
Cloud sandboxes clone the repo, so untracked local files referenced via prompt="@path/to/file.md" won't exist. This inlines those file contents at prepare time while leaving git-tracked @references for the agent to read from the sandbox. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ecd5d0ae9a
commit
d56ed52490
3 changed files with 322 additions and 1 deletions
|
|
@ -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:"),
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue