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 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-09 11:25:12 -04:00
parent e9ff7f3274
commit 4f99eb435b
4 changed files with 264 additions and 13 deletions

View file

@ -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,

View file

@ -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>) {

View file

@ -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!({

View file

@ -61,10 +61,27 @@ pub struct SetupConfig {
pub timeout_ms: Option<u64>,
}
#[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<String>,
pub preserve: Option<bool>,
pub local: Option<LocalSandboxConfig>,
pub daytona: Option<DaytonaConfig>,
pub exe: Option<arc_exe::ExeConfig>,
pub env: Option<HashMap<String, String>>,
@ -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())])),