From 4f99eb435b8afafdceaf089133e9ddec4bb8ec9c Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Mon, 9 Mar 2026 11:25:12 -0400 Subject: [PATCH] Add `[sandbox.local] worktree_mode` config for local sandbox runs Allow customizing when git worktrees are created via a new `worktree_mode` enum (always/clean/dirty/never) in `[sandbox.local]`. Previously, worktree creation was hardcoded to only occur on clean repos. Co-Authored-By: Claude Opus 4.6 --- crates/arc-api/src/demo/mod.rs | 6 + crates/arc-workflows/src/cli/progress.rs | 14 ++ crates/arc-workflows/src/cli/run.rs | 182 +++++++++++++++++++-- crates/arc-workflows/src/cli/run_config.rs | 75 +++++++++ 4 files changed, 264 insertions(+), 13 deletions(-) diff --git a/crates/arc-api/src/demo/mod.rs b/crates/arc-api/src/demo/mod.rs index 7d6eea3f2..50d936787 100644 --- a/crates/arc-api/src/demo/mod.rs +++ b/crates/arc-api/src/demo/mod.rs @@ -1265,6 +1265,7 @@ mod runs { sandbox: Some(arc_workflows::cli::run_config::SandboxConfig { provider: Some("daytona".into()), preserve: None, + local: None, daytona: Some(arc_workflows::daytona_sandbox::DaytonaConfig { auto_stop_interval: Some(60), labels: Some(std::collections::HashMap::from([( @@ -1420,6 +1421,7 @@ mod workflows { sandbox: Some(arc_workflows::cli::run_config::SandboxConfig { provider: Some("daytona".into()), preserve: None, + local: None, daytona: Some(arc_workflows::daytona_sandbox::DaytonaConfig { auto_stop_interval: Some(60), labels: Some(std::collections::HashMap::from([ @@ -1487,6 +1489,7 @@ mod workflows { sandbox: Some(arc_workflows::cli::run_config::SandboxConfig { provider: Some("daytona".into()), preserve: None, + local: None, daytona: Some(arc_workflows::daytona_sandbox::DaytonaConfig { auto_stop_interval: Some(120), labels: Some(std::collections::HashMap::from([ @@ -1566,6 +1569,7 @@ mod workflows { sandbox: Some(arc_workflows::cli::run_config::SandboxConfig { provider: Some("daytona".into()), preserve: None, + local: None, daytona: Some(arc_workflows::daytona_sandbox::DaytonaConfig { auto_stop_interval: Some(120), labels: Some(std::collections::HashMap::from([ @@ -1636,6 +1640,7 @@ mod workflows { sandbox: Some(arc_workflows::cli::run_config::SandboxConfig { provider: Some("daytona".into()), preserve: None, + local: None, daytona: Some(arc_workflows::daytona_sandbox::DaytonaConfig { auto_stop_interval: Some(180), labels: Some(std::collections::HashMap::from([ @@ -3090,6 +3095,7 @@ mod settings { sandbox: Some(arc_workflows::cli::run_config::SandboxConfig { provider: Some("daytona".into()), preserve: None, + local: None, daytona: Some(arc_workflows::daytona_sandbox::DaytonaConfig { auto_stop_interval: Some(60), labels: None, diff --git a/crates/arc-workflows/src/cli/progress.rs b/crates/arc-workflows/src/cli/progress.rs index f3e3a03ee..4501023a8 100644 --- a/crates/arc-workflows/src/cli/progress.rs +++ b/crates/arc-workflows/src/cli/progress.rs @@ -681,6 +681,20 @@ impl ProgressUI { } } + pub fn show_worktree(&mut self, path: &Path) { + let path_str = super::tilde_path(path); + match &self.renderer { + ProgressRenderer::Tty(tty) => { + let bar = tty.multi.add(ProgressBar::new_spinner()); + bar.set_style(style_static_dim()); + bar.finish_with_message(format!("Worktree: {path_str}")); + } + ProgressRenderer::Plain => { + eprintln!(" Worktree: {path_str}"); + } + } + } + // ── Stages ────────────────────────────────────────────────────────── fn on_stage_started(&mut self, node_id: &str, name: &str, script: Option<&str>) { diff --git a/crates/arc-workflows/src/cli/run.rs b/crates/arc-workflows/src/cli/run.rs index 89fa68628..fa987da9a 100644 --- a/crates/arc-workflows/src/cli/run.rs +++ b/crates/arc-workflows/src/cli/run.rs @@ -159,6 +159,25 @@ fn resolve_preserve_sandbox( .unwrap_or(false) } +/// Resolve worktree mode: TOML config > run defaults > Clean. +fn resolve_worktree_mode( + run_cfg: Option<&WorkflowRunConfig>, + run_defaults: &RunDefaults, +) -> run_config::WorktreeMode { + run_cfg + .and_then(|c| c.sandbox.as_ref()) + .and_then(|s| s.local.as_ref()) + .map(|l| l.worktree_mode) + .unwrap_or_else(|| { + run_defaults + .sandbox + .as_ref() + .and_then(|s| s.local.as_ref()) + .map(|l| l.worktree_mode) + .unwrap_or_default() + }) +} + /// Resolve daytona config: TOML config > run defaults. fn resolve_daytona_config( run_cfg: Option<&WorkflowRunConfig>, @@ -519,20 +538,47 @@ pub async fn run_command( }; // Set up git worktree for local execution (must happen before cwd is captured) - let (worktree_work_dir, _worktree_path, worktree_branch, worktree_base_sha) = if git_clean { - match setup_worktree(&original_cwd, &logs_dir, &run_id) { - Ok((wd, wt, branch, base)) => (Some(wd), Some(wt), Some(branch), Some(base)), - Err(e) => { - eprintln!( - "{} Git worktree setup failed ({e}), running without worktree.", - styles.yellow.apply_to("Warning:"), - ); - (None, None, None, None) - } - } - } else { - (None, None, None, None) + let worktree_mode = resolve_worktree_mode(run_cfg.as_ref(), &run_defaults); + let should_create_worktree = match worktree_mode { + run_config::WorktreeMode::Always => true, + run_config::WorktreeMode::Clean => git_clean, + run_config::WorktreeMode::Dirty => !git_clean, + run_config::WorktreeMode::Never => false, }; + debug!( + ?worktree_mode, + git_clean, should_create_worktree, "Resolved worktree mode" + ); + + if should_create_worktree && !git_clean { + eprintln!( + "{} Uncommitted changes will not be included in the worktree.", + styles.yellow.apply_to("Warning:"), + ); + } + + let (worktree_work_dir, _worktree_path, worktree_branch, worktree_base_sha) = + if should_create_worktree { + match setup_worktree(&original_cwd, &logs_dir, &run_id) { + Ok((wd, wt, branch, base)) => (Some(wd), Some(wt), Some(branch), Some(base)), + Err(e) => { + eprintln!( + "{} Git worktree setup failed ({e}), running without worktree.", + styles.yellow.apply_to("Warning:"), + ); + (None, None, None, None) + } + } + } else { + (None, None, None, None) + }; + + if let Some(ref wt) = _worktree_path { + progress_ui + .lock() + .expect("progress lock poisoned") + .show_worktree(wt); + } let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let daytona_config = resolve_daytona_config(run_cfg.as_ref(), &run_defaults); @@ -2146,6 +2192,7 @@ mod tests { sandbox: Some(run_config::SandboxConfig { provider: None, preserve: Some(false), + local: None, daytona: None, exe: None, env: None, @@ -2172,6 +2219,7 @@ mod tests { sandbox: Some(run_config::SandboxConfig { provider: None, preserve: Some(true), + local: None, daytona: None, exe: None, env: None, @@ -2186,6 +2234,7 @@ mod tests { sandbox: Some(run_config::SandboxConfig { provider: None, preserve: Some(false), + local: None, daytona: None, exe: None, env: None, @@ -2201,6 +2250,7 @@ mod tests { sandbox: Some(run_config::SandboxConfig { provider: None, preserve: Some(true), + local: None, daytona: None, exe: None, env: None, @@ -2216,6 +2266,112 @@ mod tests { assert!(!resolve_preserve_sandbox(false, None, &defaults)); } + #[test] + fn resolve_worktree_mode_defaults_to_clean() { + let defaults = RunDefaults::default(); + assert_eq!( + resolve_worktree_mode(None, &defaults), + run_config::WorktreeMode::Clean + ); + } + + #[test] + fn resolve_worktree_mode_from_toml() { + let cfg = run_config::WorkflowRunConfig { + version: 1, + goal: Some("test".into()), + graph: "w.dot".into(), + directory: None, + llm: None, + setup: None, + sandbox: Some(run_config::SandboxConfig { + provider: None, + preserve: None, + local: Some(run_config::LocalSandboxConfig { + worktree_mode: run_config::WorktreeMode::Always, + }), + daytona: None, + exe: None, + env: None, + }), + vars: None, + hooks: Vec::new(), + checkpoint: Default::default(), + pull_request: None, + assets: None, + }; + let defaults = RunDefaults::default(); + assert_eq!( + resolve_worktree_mode(Some(&cfg), &defaults), + run_config::WorktreeMode::Always + ); + } + + #[test] + fn resolve_worktree_mode_from_defaults() { + let defaults = RunDefaults { + sandbox: Some(run_config::SandboxConfig { + provider: None, + preserve: None, + local: Some(run_config::LocalSandboxConfig { + worktree_mode: run_config::WorktreeMode::Dirty, + }), + daytona: None, + exe: None, + env: None, + }), + ..RunDefaults::default() + }; + assert_eq!( + resolve_worktree_mode(None, &defaults), + run_config::WorktreeMode::Dirty + ); + } + + #[test] + fn resolve_worktree_mode_toml_overrides_defaults() { + let cfg = run_config::WorkflowRunConfig { + version: 1, + goal: Some("test".into()), + graph: "w.dot".into(), + directory: None, + llm: None, + setup: None, + sandbox: Some(run_config::SandboxConfig { + provider: None, + preserve: None, + local: Some(run_config::LocalSandboxConfig { + worktree_mode: run_config::WorktreeMode::Never, + }), + daytona: None, + exe: None, + env: None, + }), + vars: None, + hooks: Vec::new(), + checkpoint: Default::default(), + pull_request: None, + assets: None, + }; + let defaults = RunDefaults { + sandbox: Some(run_config::SandboxConfig { + provider: None, + preserve: None, + local: Some(run_config::LocalSandboxConfig { + worktree_mode: run_config::WorktreeMode::Dirty, + }), + daytona: None, + exe: None, + env: None, + }), + ..RunDefaults::default() + }; + assert_eq!( + resolve_worktree_mode(Some(&cfg), &defaults), + run_config::WorktreeMode::Never + ); + } + #[test] fn redact_removes_aws_key_from_compact_json() { let envelope = serde_json::json!({ diff --git a/crates/arc-workflows/src/cli/run_config.rs b/crates/arc-workflows/src/cli/run_config.rs index ee4c8f096..3924ddd35 100644 --- a/crates/arc-workflows/src/cli/run_config.rs +++ b/crates/arc-workflows/src/cli/run_config.rs @@ -61,10 +61,27 @@ pub struct SetupConfig { pub timeout_ms: Option, } +#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum WorktreeMode { + Always, + #[default] + Clean, + Dirty, + Never, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +pub struct LocalSandboxConfig { + #[serde(default)] + pub worktree_mode: WorktreeMode, +} + #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] pub struct SandboxConfig { pub provider: Option, pub preserve: Option, + pub local: Option, pub daytona: Option, pub exe: Option, pub env: Option>, @@ -130,6 +147,9 @@ impl WorkflowRunConfig { if task.preserve.is_none() { task.preserve = default.preserve; } + if task.local.is_none() { + task.local = default.local.clone(); + } match (&mut task.daytona, &default.daytona) { (Some(task_d), Some(default_d)) => { if task_d.auto_stop_interval.is_none() { @@ -952,6 +972,50 @@ provider = "docker" assert_eq!(sandbox.preserve, None); } + #[test] + fn parse_toml_worktree_mode_always() { + let toml = r#" +version = 1 +goal = "test" +graph = "w.dot" + +[sandbox.local] +worktree_mode = "always" +"#; + let config = parse_run_config(toml).unwrap(); + let local = config.sandbox.unwrap().local.unwrap(); + assert_eq!(local.worktree_mode, WorktreeMode::Always); + } + + #[test] + fn parse_toml_worktree_mode_defaults_to_clean() { + let toml = r#" +version = 1 +goal = "test" +graph = "w.dot" + +[sandbox] +provider = "local" +"#; + let config = parse_run_config(toml).unwrap(); + let sandbox = config.sandbox.unwrap(); + assert_eq!(sandbox.local, None); + } + + #[test] + fn parse_toml_worktree_mode_empty_local_defaults_to_clean() { + let toml = r#" +version = 1 +goal = "test" +graph = "w.dot" + +[sandbox.local] +"#; + let config = parse_run_config(toml).unwrap(); + let local = config.sandbox.unwrap().local.unwrap(); + assert_eq!(local.worktree_mode, WorktreeMode::Clean); + } + #[test] fn apply_defaults_merges_sandbox_preserve_task_wins() { let mut cfg = parse_run_config( @@ -969,6 +1033,7 @@ preserve = true sandbox: Some(SandboxConfig { provider: None, preserve: Some(false), + local: None, daytona: None, exe: None, env: None, @@ -996,6 +1061,7 @@ provider = "docker" sandbox: Some(SandboxConfig { provider: None, preserve: Some(true), + local: None, daytona: None, exe: None, env: None, @@ -1023,6 +1089,7 @@ provider = "daytona" sandbox: Some(SandboxConfig { provider: None, preserve: None, + local: None, daytona: Some(DaytonaConfig { auto_stop_interval: Some(30), ..DaytonaConfig::default() @@ -1059,6 +1126,7 @@ auto_stop_interval = 60 sandbox: Some(SandboxConfig { provider: Some("daytona".into()), preserve: None, + local: None, daytona: Some(DaytonaConfig { auto_stop_interval: Some(30), labels: Some(HashMap::from([("env".into(), "prod".into())])), @@ -1093,6 +1161,7 @@ env = "from_task" sandbox: Some(SandboxConfig { provider: None, preserve: None, + local: None, daytona: Some(DaytonaConfig { labels: Some(HashMap::from([ ("env".into(), "from_default".into()), @@ -1130,6 +1199,7 @@ cpu = 2 sandbox: Some(SandboxConfig { provider: None, preserve: None, + local: None, daytona: Some(DaytonaConfig { snapshot: Some(DaytonaSnapshotConfig { name: "default-snap".into(), @@ -1169,6 +1239,7 @@ auto_stop_interval = 60 sandbox: Some(SandboxConfig { provider: None, preserve: None, + local: None, daytona: Some(DaytonaConfig { snapshot: Some(DaytonaSnapshotConfig { name: "default-snap".into(), @@ -1397,6 +1468,7 @@ network = "block" sandbox: Some(SandboxConfig { provider: None, preserve: None, + local: None, daytona: Some(DaytonaConfig { network: Some(crate::daytona_sandbox::DaytonaNetwork::AllowAll), ..DaytonaConfig::default() @@ -1430,6 +1502,7 @@ auto_stop_interval = 60 sandbox: Some(SandboxConfig { provider: None, preserve: None, + local: None, daytona: Some(DaytonaConfig { network: Some(crate::daytona_sandbox::DaytonaNetwork::AllowList(vec![ "10.0.0.0/8".into(), @@ -1642,6 +1715,7 @@ SHARED = "from_task" sandbox: Some(SandboxConfig { provider: None, preserve: None, + local: None, daytona: None, exe: None, env: Some(HashMap::from([ @@ -1675,6 +1749,7 @@ provider = "daytona" sandbox: Some(SandboxConfig { provider: None, preserve: None, + local: None, daytona: None, exe: None, env: Some(HashMap::from([("KEY".into(), "val".into())])),