use crate::cli::{ExecArgs, RunArgs};
use crate::config::validate_config;
use crate::engine;
use crate::error::{CruiseError, Result};
use crate::paths;
use crate::resolver::{ConfigSource, resolve_config};
use crate::session::{SessionManager, SessionPhase, SessionState, WorkspaceMode};
pub async fn run(args: ExecArgs) -> Result<()> {
let (yaml, source) = resolve_config(args.config.as_deref())?;
let config = match source.path() {
Some(path) => crate::workflow_call::resolve_workflow_calls_from_path(path)?,
None => crate::workflow_call::resolve_workflow_calls(
crate::config::WorkflowConfig::from_yaml(&yaml)
.map_err(|e| CruiseError::ConfigParseError(e.to_string()))?,
std::env::current_dir()?,
)?,
};
validate_config(&config)?;
if args.dry_run {
engine::print_dry_run(&config, None);
return Ok(());
}
let manager = SessionManager::new(paths::data_dir()?);
let input = args.input.unwrap_or_default();
let session = setup_exec_session(&manager, &source, &yaml, input)?;
let run_args = RunArgs {
session: Some(session.id.clone()),
all: false,
max_retries: args.max_retries,
rate_limit_retries: args.rate_limit_retries,
dry_run: false,
cleanup_after_pr: false,
no_cleanup_after_pr: false,
};
crate::run_cmd::run(run_args).await
}
pub(crate) fn setup_exec_session(
manager: &SessionManager,
source: &ConfigSource,
yaml: &str,
input: String,
) -> Result<SessionState> {
let session_id = SessionManager::new_session_id();
let base_dir = std::env::current_dir()?;
let mut session =
SessionState::new(session_id.clone(), base_dir, source.display_string(), input);
session.config_path = source.path().cloned();
session.workspace_mode = WorkspaceMode::CurrentBranch;
session.phase = SessionPhase::Planned;
manager.create(&session)?;
let session_dir = manager.sessions_dir().join(&session_id);
let write_result: crate::error::Result<()> = (|| {
if session.config_path.is_none() {
std::fs::write(session_dir.join("config.yaml"), yaml)?;
}
let plan_path = session.plan_path(&manager.sessions_dir());
std::fs::write(&plan_path, "")?;
Ok(())
})();
if let Err(e) = write_result {
let _ = std::fs::remove_dir_all(&session_dir);
return Err(e);
}
Ok(session)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::DEFAULT_MAX_RETRIES;
use crate::session::{SessionPhase, WorkspaceMode};
use crate::test_support::{init_git_repo, run_git_ok};
use std::fs;
use std::path::{Path, PathBuf};
use tempfile::TempDir;
struct ProcessStateGuard {
prev_home: Option<std::ffi::OsString>,
prev_userprofile: Option<std::ffi::OsString>,
prev_path: Option<std::ffi::OsString>,
prev_xdg_data_home: Option<std::ffi::OsString>,
prev_xdg_config_home: Option<std::ffi::OsString>,
prev_xdg_state_home: Option<std::ffi::OsString>,
prev_dir: PathBuf,
lock: crate::test_support::ProcessLock,
}
impl ProcessStateGuard {
fn new(home: &Path) -> Self {
let lock = crate::test_support::lock_process();
let prev_home = std::env::var_os("HOME");
let prev_userprofile = std::env::var_os("USERPROFILE");
let prev_path = std::env::var_os("PATH");
let prev_xdg_data_home = std::env::var_os("XDG_DATA_HOME");
let prev_xdg_config_home = std::env::var_os("XDG_CONFIG_HOME");
let prev_xdg_state_home = std::env::var_os("XDG_STATE_HOME");
let prev_dir = std::env::current_dir().unwrap_or_else(|_| fallback_root());
unsafe {
std::env::set_var("HOME", home);
std::env::set_var("USERPROFILE", home);
std::env::remove_var("XDG_DATA_HOME");
std::env::remove_var("XDG_CONFIG_HOME");
std::env::remove_var("XDG_STATE_HOME");
}
Self {
prev_home,
prev_userprofile,
prev_path,
prev_xdg_data_home,
prev_xdg_config_home,
prev_xdg_state_home,
prev_dir,
lock,
}
}
fn set_current_dir(&self, dir: &Path) {
let _ = &self.lock;
let _ = std::env::set_current_dir(dir);
}
fn prepend_path(&self, dir: &Path) {
let _ = &self.lock;
let mut paths = vec![dir.to_path_buf()];
if let Some(existing) = std::env::var_os("PATH") {
paths.extend(std::env::split_paths(&existing));
}
if let Ok(joined) = std::env::join_paths(paths) {
unsafe { std::env::set_var("PATH", joined) };
}
}
}
impl Drop for ProcessStateGuard {
fn drop(&mut self) {
if std::env::set_current_dir(&self.prev_dir).is_err() {
let _ = std::env::set_current_dir(fallback_root());
}
unsafe {
macro_rules! restore {
($var:literal, $field:expr) => {
if let Some(ref v) = $field {
std::env::set_var($var, v);
} else {
std::env::remove_var($var);
}
};
}
restore!("HOME", self.prev_home);
restore!("USERPROFILE", self.prev_userprofile);
restore!("PATH", self.prev_path);
restore!("XDG_DATA_HOME", self.prev_xdg_data_home);
restore!("XDG_CONFIG_HOME", self.prev_xdg_config_home);
restore!("XDG_STATE_HOME", self.prev_xdg_state_home);
}
}
}
fn fallback_root() -> PathBuf {
#[cfg(unix)]
return PathBuf::from("/");
#[cfg(windows)]
return PathBuf::from("C:\\");
}
fn create_repo_with_origin(tmp: &TempDir) -> PathBuf {
let repo = tmp.path().join("repo");
fs::create_dir(&repo).unwrap_or_else(|e| panic!("{e:?}"));
init_git_repo(&repo);
let bare = tmp.path().join("origin.git");
run_git_ok(tmp.path(), &["init", "--bare", "origin.git"]);
run_git_ok(
&repo,
&[
"remote",
"add",
"origin",
bare.to_str().unwrap_or_else(|| panic!("unexpected None")),
],
);
repo
}
fn install_logging_gh(bin_dir: &Path, log_path: &Path, url: &str) {
fs::create_dir_all(bin_dir).unwrap_or_else(|e| panic!("{e:?}"));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let script_path = bin_dir.join("gh");
let script = format!(
"#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n printf '%s\\n' 'gh version test'\n exit 0\nfi\nprintf '%s\\n' \"$*\" >> \"{}\"\nif [ \"$1\" = \"pr\" ] && [ \"$2\" = \"create\" ]; then\n printf '%s\\n' \"{}\"\nfi\nif [ \"$1\" = \"pr\" ] && [ \"$2\" = \"view\" ]; then\n printf '%s\\n' \"{}\"\nfi\n",
log_path.display(),
url,
url
);
fs::write(&script_path, script).unwrap_or_else(|e| panic!("{e:?}"));
let mut perms = fs::metadata(&script_path)
.unwrap_or_else(|e| panic!("{e:?}"))
.permissions();
perms.set_mode(0o755);
fs::set_permissions(&script_path, perms).unwrap_or_else(|e| panic!("{e:?}"));
}
}
fn single_command_config(step_name: &str, command: &str) -> String {
format!("command:\n - cat\nsteps:\n {step_name}:\n command: |\n {command}\n")
}
fn exec_args_no_op(config_path: &Path) -> ExecArgs {
ExecArgs {
input: Some("test task".to_string()),
config: Some(
config_path
.to_str()
.unwrap_or_else(|| panic!("non-utf8 path"))
.to_string(),
),
max_retries: DEFAULT_MAX_RETRIES,
rate_limit_retries: 0,
dry_run: false,
}
}
#[test]
fn test_exec_creates_session_with_current_branch_mode() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let process = ProcessStateGuard::new(tmp.path());
let repo = create_repo_with_origin(&tmp);
process.set_current_dir(&repo);
let manager =
SessionManager::new(crate::paths::data_dir().unwrap_or_else(|e| panic!("{e:?}")));
let yaml = single_command_config("noop", "printf noop");
let config_file = tmp.path().join("cruise.yaml");
fs::write(&config_file, &yaml).unwrap_or_else(|e| panic!("{e:?}"));
let source = crate::resolver::ConfigSource::Explicit(config_file);
let session = setup_exec_session(&manager, &source, &yaml, "test task".to_string())
.unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(
session.workspace_mode,
WorkspaceMode::CurrentBranch,
"exec sessions must use CurrentBranch to skip worktree creation"
);
assert!(
matches!(session.phase, SessionPhase::Planned),
"exec sessions must start in Planned phase (not AwaitingApproval)"
);
assert_eq!(session.input, "test task");
assert_eq!(
session
.base_dir
.canonicalize()
.unwrap_or_else(|e| panic!("{e:?}")),
repo.canonicalize().unwrap_or_else(|e| panic!("{e:?}"))
);
}
#[test]
fn test_exec_writes_placeholder_plan_md() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let process = ProcessStateGuard::new(tmp.path());
let repo = create_repo_with_origin(&tmp);
process.set_current_dir(&repo);
let manager =
SessionManager::new(crate::paths::data_dir().unwrap_or_else(|e| panic!("{e:?}")));
let yaml = single_command_config("noop", "printf noop");
let config_file = tmp.path().join("cruise.yaml");
fs::write(&config_file, &yaml).unwrap_or_else(|e| panic!("{e:?}"));
let source = crate::resolver::ConfigSource::Explicit(config_file);
let session = setup_exec_session(&manager, &source, &yaml, "placeholder test".to_string())
.unwrap_or_else(|e| panic!("{e:?}"));
let plan_path = session.plan_path(&manager.sessions_dir());
assert!(
plan_path.exists(),
"plan.md placeholder must exist so {{plan}} references don't fail"
);
let content = fs::read_to_string(&plan_path).unwrap_or_else(|e| panic!("{e:?}"));
assert!(
content.is_empty(),
"placeholder plan.md should be empty, got: {content:?}"
);
}
#[test]
fn test_exec_uses_explicit_config_path() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let process = ProcessStateGuard::new(tmp.path());
let repo = create_repo_with_origin(&tmp);
process.set_current_dir(&repo);
let config_file = tmp.path().join("my.yaml");
let yaml = single_command_config("noop", "printf noop");
fs::write(&config_file, &yaml).unwrap_or_else(|e| panic!("{e:?}"));
let source = crate::resolver::ConfigSource::Explicit(config_file.clone());
let manager =
SessionManager::new(crate::paths::data_dir().unwrap_or_else(|e| panic!("{e:?}")));
let session =
setup_exec_session(&manager, &source, &yaml, "explicit config test".to_string())
.unwrap_or_else(|e| panic!("{e:?}"));
let stored = session
.config_path
.clone()
.unwrap_or_else(|| panic!("expected config_path to be set for explicit source"));
assert!(
stored.is_absolute(),
"config_path must be absolute, got: {stored:?}"
);
assert_eq!(
stored, config_file,
"config_path must point to the supplied file"
);
let session_config = manager.sessions_dir().join(&session.id).join("config.yaml");
assert!(
!session_config.exists(),
"explicit config must NOT be copied into session_dir"
);
}
#[tokio::test(flavor = "current_thread")]
async fn test_exec_does_not_create_worktree_or_pr() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let process = ProcessStateGuard::new(tmp.path());
let repo = create_repo_with_origin(&tmp);
process.set_current_dir(&repo);
let config_content = single_command_config("write", "printf exec-result > exec-out.txt");
let config_path = tmp.path().join("cruise.yaml");
fs::write(&config_path, &config_content).unwrap_or_else(|e| panic!("{e:?}"));
let bin_dir = tmp.path().join("bin");
let gh_log = tmp.path().join("gh.log");
install_logging_gh(&bin_dir, &gh_log, "https://github.com/owner/repo/pull/99");
process.prepend_path(&bin_dir);
let result = run(exec_args_no_op(&config_path)).await;
assert!(
result.is_ok(),
"exec in a clean repo should succeed: {result:?}"
);
assert!(
repo.join("exec-out.txt").exists(),
"exec should write changes directly into the working directory"
);
assert_eq!(
fs::read_to_string(repo.join("exec-out.txt")).unwrap_or_else(|e| panic!("{e:?}")),
"exec-result"
);
assert!(
!gh_log.exists(),
"exec should not invoke gh (no PR creation)"
);
let manager =
SessionManager::new(crate::paths::data_dir().unwrap_or_else(|e| panic!("{e:?}")));
let sessions = manager.list().unwrap_or_else(|e| panic!("{e:?}"));
let repo_canonical = repo.canonicalize().unwrap_or_else(|e| panic!("{e:?}"));
let exec_session = sessions
.iter()
.find(|s| s.base_dir.canonicalize().is_ok_and(|p| p == repo_canonical))
.unwrap_or_else(|| panic!("expected an exec session to be recorded"));
assert!(
exec_session.worktree_path.is_none(),
"exec sessions must not have a worktree path"
);
assert!(
exec_session.pr_url.is_none(),
"exec sessions must not have a PR url"
);
}
#[tokio::test(flavor = "current_thread")]
async fn test_exec_rejects_dirty_working_tree() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let process = ProcessStateGuard::new(tmp.path());
let repo = create_repo_with_origin(&tmp);
process.set_current_dir(&repo);
let config_content = single_command_config("noop", "printf noop");
let config_path = tmp.path().join("cruise.yaml");
fs::write(&config_path, &config_content).unwrap_or_else(|e| panic!("{e:?}"));
fs::write(repo.join("README.md"), "modified").unwrap_or_else(|e| panic!("{e:?}"));
let result = run(exec_args_no_op(&config_path)).await;
assert!(
result.is_err(),
"exec must reject a dirty working tree to avoid overwriting uncommitted changes"
);
let message = result.map_or_else(|e| e.to_string(), |()| String::new());
assert!(
message.contains("dirty"),
"error message should mention dirty tree, got: {message}"
);
}
#[tokio::test(flavor = "current_thread")]
async fn test_exec_dry_run_prints_flow_without_executing() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let process = ProcessStateGuard::new(tmp.path());
let repo = create_repo_with_origin(&tmp);
process.set_current_dir(&repo);
let config_content =
single_command_config("write", "printf should-not-exist > dry-run-out.txt");
let config_path = repo.join("cruise.yaml");
fs::write(&config_path, &config_content).unwrap_or_else(|e| panic!("{e:?}"));
let args = ExecArgs {
input: None,
config: Some(
config_path
.to_str()
.unwrap_or_else(|| panic!("non-utf8"))
.to_string(),
),
max_retries: DEFAULT_MAX_RETRIES,
rate_limit_retries: 0,
dry_run: true,
};
let result = run(args).await;
assert!(
result.is_ok(),
"dry-run should return Ok without executing steps: {result:?}"
);
assert!(
!repo.join("dry-run-out.txt").exists(),
"dry-run must not execute steps or write any files"
);
let manager =
SessionManager::new(crate::paths::data_dir().unwrap_or_else(|e| panic!("{e:?}")));
let sessions = manager.list().unwrap_or_else(|e| panic!("{e:?}"));
assert!(
sessions.iter().all(|s| s.base_dir != repo),
"dry-run must not create a session"
);
}
}