#![expect( clippy::disallowed_methods, reason = "These git integration tests intentionally exercise the real git CLI to validate repository helper behavior." )] use std::collections::HashMap; use std::path::Path; use std::process::{Command, Output}; use std::sync::Arc; use fabro_agent::Sandbox; use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node}; use fabro_types::{RunEvent, WorkflowSettings, fixtures}; use fabro_workflow::event::Emitter; use fabro_workflow::git::{branch_needs_push, push_branch, push_ref}; use fabro_workflow::handler::HandlerRegistry; use fabro_workflow::handler::exit::ExitHandler; use fabro_workflow::handler::start::StartHandler; use fabro_workflow::run_options::{GitCheckpointOptions, RunOptions}; use fabro_workflow::test_support::run_graph; use tokio_util::sync::CancellationToken; fn assert_success(output: &Output, context: &str) { assert!( output.status.success(), "{context} failed: {}", String::from_utf8_lossy(&output.stderr) ); } fn init_repo(dir: &Path) { std::fs::create_dir_all(dir).expect("failed to create repo dir"); let init = Command::new("git") .args(["init"]) .current_dir(dir) .output() .expect("git init should run"); assert_success(&init, "git init"); let commit = Command::new("git") .args([ "-c", "user.name=test", "-c", "user.email=test@test", "commit", "--allow-empty", "-m", "init", ]) .current_dir(dir) .output() .expect("git commit --allow-empty should run"); assert_success(&commit, "git commit --allow-empty"); } fn init_bare_remote(dir: &Path) { std::fs::create_dir_all( dir.parent() .expect("bare remote path should have a parent directory"), ) .expect("failed to create bare remote parent dir"); let init = Command::new("git") .args(["init", "--bare"]) .arg(dir) .output() .expect("git init --bare should run"); assert_success(&init, "git init --bare"); } fn add_origin(repo_dir: &Path, remote_dir: &Path) { let output = Command::new("git") .args(["remote", "add", "origin"]) .arg(remote_dir) .current_dir(repo_dir) .output() .expect("git remote add origin should run"); assert_success(&output, "git remote add origin"); } fn rename_branch(repo_dir: &Path, branch: &str) { let output = Command::new("git") .args(["branch", "-M", branch]) .current_dir(repo_dir) .output() .expect("git branch -M should run"); assert_success(&output, "git branch -M"); } fn empty_commit(repo_dir: &Path, message: &str) { let output = Command::new("git") .args([ "-c", "user.name=test", "-c", "user.email=test@test", "commit", "--allow-empty", "-m", message, ]) .current_dir(repo_dir) .output() .expect("git commit --allow-empty should run"); assert_success(&output, "git commit --allow-empty"); } fn list_branch(repo_dir: &Path, branch: &str) -> String { let output = Command::new("git") .args(["branch", "--list", branch]) .current_dir(repo_dir) .output() .expect("git branch --list should run"); assert_success(&output, "git branch --list"); String::from_utf8(output.stdout).expect("git branch --list output should be UTF-8") } fn local_env(repo: &Path) -> Arc { Arc::new(fabro_agent::LocalSandbox::new(repo.to_path_buf())) } fn simple_graph() -> Graph { let mut g = Graph::new("git_checkpoint"); g.attrs.insert( "goal".to_string(), AttrValue::String("Create git checkpoints".to_string()), ); let mut start = Node::new("start"); start.attrs.insert( "shape".to_string(), AttrValue::String("Mdiamond".to_string()), ); g.nodes.insert("start".to_string(), start); let mut exit = Node::new("exit"); exit.attrs.insert( "shape".to_string(), AttrValue::String("Msquare".to_string()), ); g.nodes.insert("exit".to_string(), exit); g } fn make_registry() -> HandlerRegistry { let mut registry = HandlerRegistry::new(Box::new(StartHandler)); registry.register("start", Box::new(StartHandler)); registry.register("exit", Box::new(ExitHandler)); registry } fn test_run_options(run_dir: &Path) -> RunOptions { RunOptions { run_dir: run_dir.to_path_buf(), cancel_token: CancellationToken::new(), run_id: fixtures::RUN_2, settings: WorkflowSettings::default(), git: None, pre_run_git: None, fork_source_ref: None, labels: HashMap::new(), github_app: None, base_branch: None, display_base_sha: None, workflow_slug: None, } } #[test] fn push_ref_to_bare_remote() { let dir = tempfile::tempdir().unwrap(); let repo_dir = dir.path().join("repo"); let remote_dir = dir.path().join("remote.git"); init_bare_remote(&remote_dir); init_repo(&repo_dir); add_origin(&repo_dir, &remote_dir); rename_branch(&repo_dir, "test-push"); let url = format!("file://{}", remote_dir.display()); push_ref(&repo_dir, &url, "refs/heads/test-push").unwrap(); assert!(list_branch(&remote_dir, "test-push").contains("test-push")); } #[test] fn push_branch_to_remote() { let dir = tempfile::tempdir().unwrap(); let repo_dir = dir.path().join("repo"); let remote_dir = dir.path().join("remote.git"); init_bare_remote(&remote_dir); init_repo(&repo_dir); add_origin(&repo_dir, &remote_dir); rename_branch(&repo_dir, "main"); push_branch(&repo_dir, "origin", "main").unwrap(); assert!(list_branch(&remote_dir, "main").contains("main")); } #[test] fn branch_needs_push_when_ahead() { let dir = tempfile::tempdir().unwrap(); let repo_dir = dir.path().join("repo"); let remote_dir = dir.path().join("remote.git"); init_bare_remote(&remote_dir); init_repo(&repo_dir); add_origin(&repo_dir, &remote_dir); rename_branch(&repo_dir, "main"); push_branch(&repo_dir, "origin", "main").unwrap(); empty_commit(&repo_dir, "second"); assert!(branch_needs_push(&repo_dir, "origin", "main")); } #[test] fn branch_needs_push_when_in_sync() { let dir = tempfile::tempdir().unwrap(); let repo_dir = dir.path().join("repo"); let remote_dir = dir.path().join("remote.git"); init_bare_remote(&remote_dir); init_repo(&repo_dir); add_origin(&repo_dir, &remote_dir); rename_branch(&repo_dir, "main"); push_branch(&repo_dir, "origin", "main").unwrap(); assert!(!branch_needs_push(&repo_dir, "origin", "main")); } #[tokio::test] async fn git_checkpoint_skips_start_node() { let repo_dir = tempfile::tempdir().unwrap(); let repo = repo_dir.path(); init_repo(repo); let base_sha = String::from_utf8( Command::new("git") .args(["rev-parse", "HEAD"]) .current_dir(repo) .output() .unwrap() .stdout, ) .unwrap() .trim() .to_string(); let run_tmp = tempfile::tempdir().unwrap(); let mut g = simple_graph(); g.nodes.insert("work".to_string(), Node::new("work")); g.edges.clear(); g.edges.push(Edge::new("start", "work")); g.edges.push(Edge::new("work", "exit")); let events = Arc::new(std::sync::Mutex::new(Vec::::new())); let events_clone = Arc::clone(&events); let emitter = Emitter::new(fixtures::RUN_2); emitter.on_event(move |event| { events_clone.lock().unwrap().push(event.clone()); }); let mut run_options = test_run_options(run_tmp.path()); run_options.git = Some(GitCheckpointOptions { base_sha: Some(base_sha), run_branch: None, meta_branch: Some(format!("fabro/meta/{}", fixtures::RUN_2)), }); Box::pin(run_graph( make_registry(), Arc::new(emitter), local_env(repo), &g, &run_options, )) .await .unwrap(); let collected = events.lock().unwrap(); let checkpoint_node_ids: Vec<&str> = collected .iter() .filter(|event| { event.event_name() == "checkpoint.completed" && event.properties().is_ok_and(|properties| { properties .get("git_commit_sha") .and_then(|value| value.as_str()) .is_some() }) }) .filter_map(|event| event.node_id.as_deref()) .collect(); assert!(!checkpoint_node_ids.contains(&"start")); assert!(checkpoint_node_ids.contains(&"work")); } /// Sandbox double for remote-style runs: commands and files operate on a real /// local checkout, but the workflow engine's run directory is reported as /// inaccessible (as it is for Docker/Daytona) and the sandbox exposes a /// runtime directory outside the checkout. struct RemoteRuntimeSandbox { inner: fabro_agent::LocalSandbox, hidden_path: String, runtime_directory: String, } #[async_trait::async_trait] impl Sandbox for RemoteRuntimeSandbox { async fn read_file_bytes(&self, path: &str) -> fabro_sandbox::Result> { self.inner.read_file_bytes(path).await } async fn write_file(&self, path: &str, content: &str) -> fabro_sandbox::Result<()> { self.inner.write_file(path, content).await } async fn delete_file(&self, path: &str) -> fabro_sandbox::Result<()> { self.inner.delete_file(path).await } async fn file_exists(&self, path: &str) -> fabro_sandbox::Result { if path == self.hidden_path { return Ok(false); } self.inner.file_exists(path).await } async fn list_directory( &self, path: &str, depth: Option, ) -> fabro_sandbox::Result> { self.inner.list_directory(path, depth).await } async fn exec_command( &self, command: &str, timeout_ms: u64, working_dir: Option<&str>, env_vars: Option<&HashMap>, cancel_token: Option, ) -> fabro_sandbox::Result { self.inner .exec_command(command, timeout_ms, working_dir, env_vars, cancel_token) .await } async fn grep( &self, pattern: &str, path: &str, options: &fabro_sandbox::GrepOptions, ) -> fabro_sandbox::Result> { self.inner.grep(pattern, path, options).await } async fn download_file_to_local( &self, remote_path: &str, local_path: &Path, ) -> fabro_sandbox::Result<()> { self.inner .download_file_to_local(remote_path, local_path) .await } async fn upload_file_from_local( &self, local_path: &Path, remote_path: &str, ) -> fabro_sandbox::Result<()> { self.inner .upload_file_from_local(local_path, remote_path) .await } async fn initialize(&self) -> fabro_sandbox::Result<()> { Ok(()) } async fn cleanup(&self) -> fabro_sandbox::Result<()> { Ok(()) } fn working_directory(&self) -> &str { self.inner.working_directory() } fn runtime_directory(&self) -> Option<&str> { Some(&self.runtime_directory) } fn platform(&self) -> &str { self.inner.platform() } fn os_version(&self) -> String { self.inner.os_version() } } fn git_status_porcelain(repo_dir: &Path) -> String { let output = Command::new("git") .args(["status", "--porcelain"]) .current_dir(repo_dir) .output() .expect("git status --porcelain should run"); assert_success(&output, "git status --porcelain"); String::from_utf8(output.stdout).expect("git status output should be UTF-8") } fn git_committed_files(repo_dir: &Path, sha: &str) -> String { let output = Command::new("git") .args(["show", "--name-only", "--format=", sha]) .current_dir(repo_dir) .output() .expect("git show --name-only should run"); assert_success(&output, "git show --name-only"); String::from_utf8(output.stdout).expect("git show output should be UTF-8") } /// Remote-style prompt demotion must materialize blobs in the sandbox runtime /// directory, outside the checkout, so a real checkpoint commit can never pick /// them up, and re-resolution must recreate a deleted materialized file from /// the durable blob store. Regression test for issue #798. #[tokio::test] async fn remote_prompt_demotion_stays_outside_checkout_and_survives_checkpoint() { use std::time::Duration; use fabro_store::test_support as store_test_support; use fabro_types::settings::run::RunCheckpointSettings; use fabro_workflow::context::Context; use fabro_workflow::git::GitAuthor; use fabro_workflow::runtime_store::RunStoreHandle; use fabro_workflow::{artifact, sandbox_git}; use object_store::memory::InMemory; let dir = tempfile::tempdir().unwrap(); let repo_dir = dir.path().join("repo"); init_repo(&repo_dir); let runtime_dir = dir.path().join("fabro").join("runtime"); let run_dir = dir.path().join("run"); std::fs::create_dir_all(&run_dir).unwrap(); let sandbox = RemoteRuntimeSandbox { inner: fabro_agent::LocalSandbox::new(repo_dir.clone()), hidden_path: run_dir.to_string_lossy().to_string(), runtime_directory: runtime_dir.to_string_lossy().to_string(), }; let store = store_test_support::test_database( Arc::new(InMemory::new()), "runs/", Duration::from_millis(1), None, ); let run_store: RunStoreHandle = store.create_run(&fixtures::RUN_2).await.unwrap().into(); let oversized = serde_json::json!("x".repeat(64 * 1024)); let oversized_bytes = serde_json::to_vec(&oversized).unwrap(); let mut values = HashMap::from([("dataset".to_string(), oversized.clone())]); artifact::demote_large_values_for_prompt( &mut values, &mut HashMap::new(), &run_store, &sandbox, &run_dir, ) .await; let marker = values["dataset"] .get("fabroLargeValue") .expect("oversized value should demote to a marker"); let blob_path = marker["path"].as_str().unwrap().to_string(); assert!( blob_path.starts_with(&runtime_dir.to_string_lossy().to_string()), "materialized blob {blob_path} should live under the sandbox runtime directory" ); assert!( !blob_path.starts_with(&repo_dir.to_string_lossy().to_string()), "materialized blob {blob_path} must not live inside the checkout" ); // The agent-facing path is readable through the sandbox. let contents = sandbox.read_file_bytes(&blob_path).await.unwrap(); assert_eq!(contents, oversized_bytes); // Materialization leaves the checkout clean, and a real checkpoint commit // stages no runtime blob file. assert_eq!(git_status_porcelain(&repo_dir), ""); let sha = sandbox_git::git_checkpoint( &sandbox, &fixtures::RUN_2.to_string(), "work", "succeeded", 1, None, &RunCheckpointSettings::default(), &GitAuthor::default(), ) .await .expect("checkpoint commit should succeed"); assert_eq!(git_committed_files(&repo_dir, &sha).trim(), ""); assert_eq!(git_status_porcelain(&repo_dir), ""); // Removing the materialized file and resolving the value again recreates // it from the durable blob store. std::fs::remove_file(&blob_path).unwrap(); let blob_hash = fabro_types::BlobHash::new(&oversized_bytes); let context = Context::new(); context.set( "report", serde_json::json!(fabro_types::format_blob_ref(&blob_hash)), ); let resolved = artifact::resolved_context_snapshot(&context, &run_store, &sandbox, &run_dir) .await .unwrap(); assert_eq!( resolved["report"], serde_json::json!(format!("file://{blob_path}")) ); assert_eq!( sandbox.read_file_bytes(&blob_path).await.unwrap(), oversized_bytes ); }