mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-07 08:27:12 +00:00
Add arc diff command to view workflow run diffs
Surfaces existing final.patch and per-node diff.patch files via CLI. Supports live diffs from in-progress runs by reconnecting to the sandbox. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
af4cad399b
commit
5f9c211031
3 changed files with 286 additions and 0 deletions
|
|
@ -64,6 +64,9 @@ enum Command {
|
|||
Preview(arc_workflows::cli::preview::PreviewArgs),
|
||||
/// SSH into a run's Daytona sandbox
|
||||
Ssh(arc_workflows::cli::ssh::SshArgs),
|
||||
/// Show the diff of changes from a workflow run
|
||||
#[command(hide = true)]
|
||||
Diff(arc_workflows::cli::diff::DiffArgs),
|
||||
/// List and test LLM models
|
||||
Model {
|
||||
#[command(subcommand)]
|
||||
|
|
@ -183,6 +186,7 @@ async fn main_inner() -> Result<()> {
|
|||
Command::Cp(_) => "cp",
|
||||
Command::Preview(_) => "preview",
|
||||
Command::Ssh(_) => "ssh",
|
||||
Command::Diff(_) => "diff",
|
||||
Command::Model { .. } => "model",
|
||||
#[cfg(feature = "server")]
|
||||
Command::Serve(_) => "serve",
|
||||
|
|
@ -361,6 +365,9 @@ async fn main_inner() -> Result<()> {
|
|||
Command::Ssh(args) => {
|
||||
arc_workflows::cli::ssh::ssh_command(args).await?;
|
||||
}
|
||||
Command::Diff(args) => {
|
||||
arc_workflows::cli::diff::diff_command(args).await?;
|
||||
}
|
||||
Command::Model { command } => {
|
||||
let cli_config = cli_config::load_cli_config(None)?;
|
||||
let resolved =
|
||||
|
|
|
|||
278
lib/crates/arc-workflows/src/cli/diff.rs
Normal file
278
lib/crates/arc-workflows/src/cli/diff.rs
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
use std::io::{self, IsTerminal, Write};
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use clap::Args;
|
||||
use tracing::{debug, info};
|
||||
|
||||
use crate::cli::runs::{default_runs_base, find_run_by_prefix};
|
||||
use crate::manifest::Manifest;
|
||||
use crate::sandbox_record::SandboxRecord;
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct DiffArgs {
|
||||
/// Run ID or prefix
|
||||
pub run: String,
|
||||
/// Show diff for a specific node
|
||||
#[arg(long)]
|
||||
pub node: Option<String>,
|
||||
/// Show diffstat instead of full patch (live diffs only)
|
||||
#[arg(long)]
|
||||
pub stat: bool,
|
||||
}
|
||||
|
||||
pub async fn diff_command(args: DiffArgs) -> Result<()> {
|
||||
info!(run_id = %args.run, "Showing diff");
|
||||
let base = default_runs_base();
|
||||
let run_dir = find_run_by_prefix(&base, &args.run)?;
|
||||
|
||||
let patch = resolve_diff(&run_dir, &args).await?;
|
||||
|
||||
let is_tty = io::stdout().is_terminal();
|
||||
let mut stdout = io::stdout().lock();
|
||||
if is_tty {
|
||||
for line in patch.lines() {
|
||||
writeln!(stdout, "{}", colorize_diff_line(line))?;
|
||||
}
|
||||
} else {
|
||||
stdout.write_all(patch.as_bytes())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn resolve_diff(run_dir: &Path, args: &DiffArgs) -> Result<String> {
|
||||
// --node: read per-node diff.patch
|
||||
if let Some(ref node_id) = args.node {
|
||||
debug!(node_id, "Reading per-node diff");
|
||||
let node_patch = run_dir.join("nodes").join(node_id).join("diff.patch");
|
||||
return std::fs::read_to_string(&node_patch).with_context(|| {
|
||||
format!("No diff found for node '{node_id}' — check the node ID and try again")
|
||||
});
|
||||
}
|
||||
|
||||
let manifest =
|
||||
Manifest::load(&run_dir.join("manifest.json")).context("Failed to load manifest.json")?;
|
||||
|
||||
let base_sha = manifest
|
||||
.base_sha
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("This run was not git-checkpointed; no diff available"))?;
|
||||
|
||||
// Completed run with final.patch
|
||||
let final_patch_path = run_dir.join("final.patch");
|
||||
if final_patch_path.exists() {
|
||||
debug!("Reading final.patch");
|
||||
return std::fs::read_to_string(&final_patch_path).context("Failed to read final.patch");
|
||||
}
|
||||
|
||||
// Check if the run has concluded (no final.patch means no changes or error)
|
||||
let conclusion_path = run_dir.join("conclusion.json");
|
||||
if conclusion_path.exists() {
|
||||
bail!(
|
||||
"Run completed but no final.patch exists — the run may not have produced any changes"
|
||||
);
|
||||
}
|
||||
|
||||
// In-progress run: reconnect to sandbox and run git diff
|
||||
debug!("No final.patch found; attempting live diff from sandbox");
|
||||
let sandbox_json = run_dir.join("sandbox.json");
|
||||
let record = SandboxRecord::load(&sandbox_json).context(
|
||||
"Failed to load sandbox.json — was this run started with a recent version of arc?",
|
||||
)?;
|
||||
|
||||
info!(provider = %record.provider, "Reconnecting to sandbox for live diff");
|
||||
let sandbox = crate::cli::cp::reconnect(&record).await?;
|
||||
|
||||
let stat_flag = if args.stat { " --stat" } else { "" };
|
||||
let cmd = format!(
|
||||
"git -c maintenance.auto=0 -c gc.auto=0 diff{stat_flag} {} HEAD",
|
||||
shlex::try_quote(base_sha).unwrap_or_else(|_| base_sha.into())
|
||||
);
|
||||
debug!(cmd, "Running git diff in sandbox");
|
||||
|
||||
let result = sandbox
|
||||
.exec_command(&cmd, 30_000, None, None, None)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to run git diff in sandbox: {e}"))?;
|
||||
|
||||
if result.exit_code != 0 {
|
||||
let stderr = result.stderr.trim();
|
||||
bail!("git diff failed (exit {}):\n{stderr}", result.exit_code);
|
||||
}
|
||||
|
||||
Ok(result.stdout)
|
||||
}
|
||||
|
||||
fn colorize_diff_line(line: &str) -> String {
|
||||
if line.starts_with("+++") || line.starts_with("---") {
|
||||
format!("\x1b[1m{line}\x1b[0m")
|
||||
} else if line.starts_with('+') {
|
||||
format!("\x1b[32m{line}\x1b[0m")
|
||||
} else if line.starts_with('-') {
|
||||
format!("\x1b[31m{line}\x1b[0m")
|
||||
} else if line.starts_with("@@") {
|
||||
format!("\x1b[36m{line}\x1b[0m")
|
||||
} else if line.starts_with("diff ") {
|
||||
format!("\x1b[1m{line}\x1b[0m")
|
||||
} else {
|
||||
line.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
fn create_manifest(dir: &Path, base_sha: Option<&str>) {
|
||||
let manifest = serde_json::json!({
|
||||
"run_id": "test-run-001",
|
||||
"workflow_name": "test",
|
||||
"goal": "test goal",
|
||||
"start_time": "2025-01-01T00:00:00Z",
|
||||
"node_count": 1,
|
||||
"edge_count": 0,
|
||||
"base_sha": base_sha,
|
||||
"labels": {},
|
||||
});
|
||||
fs::write(
|
||||
dir.join("manifest.json"),
|
||||
serde_json::to_string_pretty(&manifest).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn create_conclusion(dir: &Path) {
|
||||
let conclusion = serde_json::json!({
|
||||
"timestamp": "2025-01-01T00:01:00Z",
|
||||
"status": "success",
|
||||
"duration_ms": 60000,
|
||||
});
|
||||
fs::write(
|
||||
dir.join("conclusion.json"),
|
||||
serde_json::to_string_pretty(&conclusion).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn completed_run_with_final_patch() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
create_manifest(dir.path(), Some("abc123"));
|
||||
|
||||
let patch_content = "diff --git a/file.txt b/file.txt\n--- a/file.txt\n+++ b/file.txt\n@@ -1 +1 @@\n-old\n+new\n";
|
||||
fs::write(dir.path().join("final.patch"), patch_content).unwrap();
|
||||
|
||||
let args = DiffArgs {
|
||||
run: String::new(),
|
||||
node: None,
|
||||
stat: false,
|
||||
};
|
||||
let result = resolve_diff(dir.path(), &args).await.unwrap();
|
||||
assert_eq!(result, patch_content);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn per_node_diff() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let node_dir = dir.path().join("nodes").join("work");
|
||||
fs::create_dir_all(&node_dir).unwrap();
|
||||
|
||||
let patch_content = "diff --git a/src/main.rs b/src/main.rs\n+added line\n";
|
||||
fs::write(node_dir.join("diff.patch"), patch_content).unwrap();
|
||||
|
||||
let args = DiffArgs {
|
||||
run: String::new(),
|
||||
node: Some("work".to_string()),
|
||||
stat: false,
|
||||
};
|
||||
let result = resolve_diff(dir.path(), &args).await.unwrap();
|
||||
assert_eq!(result, patch_content);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_base_sha_errors() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
create_manifest(dir.path(), None);
|
||||
|
||||
let args = DiffArgs {
|
||||
run: String::new(),
|
||||
node: None,
|
||||
stat: false,
|
||||
};
|
||||
let err = resolve_diff(dir.path(), &args).await.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("not git-checkpointed"),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn completed_run_no_final_patch() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
create_manifest(dir.path(), Some("abc123"));
|
||||
create_conclusion(dir.path());
|
||||
|
||||
let args = DiffArgs {
|
||||
run: String::new(),
|
||||
node: None,
|
||||
stat: false,
|
||||
};
|
||||
let err = resolve_diff(dir.path(), &args).await.unwrap_err();
|
||||
assert!(err.to_string().contains("no final.patch"), "got: {err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn node_diff_not_found() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
let args = DiffArgs {
|
||||
run: String::new(),
|
||||
node: Some("nonexistent".to_string()),
|
||||
stat: false,
|
||||
};
|
||||
let err = resolve_diff(dir.path(), &args).await.unwrap_err();
|
||||
assert!(err.to_string().contains("nonexistent"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colorize_added_line() {
|
||||
let result = colorize_diff_line("+added");
|
||||
assert!(result.contains("\x1b[32m"));
|
||||
assert!(result.contains("+added"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colorize_removed_line() {
|
||||
let result = colorize_diff_line("-removed");
|
||||
assert!(result.contains("\x1b[31m"));
|
||||
assert!(result.contains("-removed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colorize_hunk_header() {
|
||||
let result = colorize_diff_line("@@ -1,3 +1,4 @@");
|
||||
assert!(result.contains("\x1b[36m"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colorize_diff_header() {
|
||||
let result = colorize_diff_line("diff --git a/file b/file");
|
||||
assert!(result.contains("\x1b[1m"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colorize_file_header() {
|
||||
let plus = colorize_diff_line("+++ b/file.txt");
|
||||
assert!(plus.contains("\x1b[1m"), "got: {plus}");
|
||||
|
||||
let minus = colorize_diff_line("--- a/file.txt");
|
||||
assert!(minus.contains("\x1b[1m"), "got: {minus}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colorize_context_line_unchanged() {
|
||||
let result = colorize_diff_line(" context line");
|
||||
assert_eq!(result, " context line");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
pub mod backend;
|
||||
pub mod cli_backend;
|
||||
pub mod cp;
|
||||
pub mod diff;
|
||||
pub mod parse;
|
||||
pub mod pr;
|
||||
pub mod preview;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue