use std::cell::{Cell, RefCell};
use std::collections::HashSet;
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use console::style;
use inquire::InquireError;
use crate::cancellation::CancellationToken;
use crate::cli::RunArgs;
use crate::config::validate_config;
use crate::engine::{execute_steps, print_dry_run};
use crate::error::{CruiseError, Result};
use crate::file_tracker::FileTracker;
use crate::option_handler::CliOptionHandler;
use crate::session::PLAN_VAR;
use crate::session::{
SessionFileContents, SessionLogger, SessionManager, SessionPhase, SessionState,
SessionStateFingerprint, WorkspaceMode, current_iso8601, get_cruise_home,
};
use crate::variable::VariableStore;
use crate::workflow::CompiledWorkflow;
use crate::workspace::{ExecutionWorkspace, prepare_execution_workspace, update_session_workspace};
const SESSION_STATE_CONFLICT_ABORT_LABEL: &str = "Abort run";
const SESSION_STATE_CONFLICT_OVERWRITE_LABEL: &str = "Overwrite external state";
const WORKSPACE_WORKTREE_LABEL: &str = "Create worktree (new branch)";
const WORKSPACE_CURRENT_BRANCH_LABEL: &str = "Use current branch";
#[cfg(test)]
const TEST_STATE_CONFLICT_ACTION_ENV: &str = "CRUISE_TEST_STATE_CONFLICT_ACTION";
#[cfg(test)]
const TEST_STATE_CONFLICT_LOG_ENV: &str = "CRUISE_TEST_STATE_CONFLICT_LOG";
#[cfg(test)]
const TEST_STDIN_IS_TERMINAL_ENV: &str = "CRUISE_TEST_STDIN_IS_TERMINAL";
#[cfg(test)]
const TEST_WORKSPACE_MODE_ENV: &str = "CRUISE_TEST_WORKSPACE_MODE";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WorkspaceOverride {
RespectSession,
ForceWorktree,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SessionStateConflictChoice {
Abort,
Overwrite,
}
fn fallback_root() -> PathBuf {
#[cfg(windows)]
{
PathBuf::from(std::env::var("SYSTEMDRIVE").unwrap_or_else(|_| "C:".into()) + "\\")
}
#[cfg(not(windows))]
{
PathBuf::from("/")
}
}
struct CurrentDirGuard {
original: PathBuf,
}
impl CurrentDirGuard {
fn capture() -> Result<Self> {
Ok(Self {
original: std::env::current_dir()?,
})
}
}
impl Drop for CurrentDirGuard {
fn drop(&mut self) {
if std::env::set_current_dir(&self.original).is_err() {
let _ = std::env::set_current_dir(fallback_root());
}
}
}
fn save_session_state_with_conflict_resolution(
manager: &SessionManager,
session: &SessionState,
expected_fingerprint: SessionStateFingerprint,
) -> Result<SessionStateFingerprint> {
let current_contents = manager.inspect_state_file(&session.id)?;
let current_fingerprint = current_contents.fingerprint();
if current_fingerprint == Some(expected_fingerprint) {
return manager.save_with_fingerprint(session);
}
let state_path = manager.state_path(&session.id);
let message = session_state_conflict_message(&state_path, ¤t_contents);
if !stdin_is_terminal() {
return Err(CruiseError::SessionStateConflict(message));
}
match prompt_for_session_state_conflict(&message)? {
SessionStateConflictChoice::Abort => {
#[cfg(test)]
record_session_state_conflict_choice("abort");
Err(CruiseError::SessionStateConflictAborted(message))
}
SessionStateConflictChoice::Overwrite => {
#[cfg(test)]
record_session_state_conflict_choice("overwrite");
manager.save_with_fingerprint(session)
}
}
}
fn session_state_conflict_message(path: &Path, current_contents: &SessionFileContents) -> String {
match current_contents {
SessionFileContents::Missing => {
format!(
"{} was deleted while the session was running",
path.display()
)
}
SessionFileContents::Parsed { .. } => {
format!(
"{} changed externally while the session was running",
path.display()
)
}
SessionFileContents::Invalid { error, .. } => format!(
"{} changed externally and now contains invalid JSON: {}",
path.display(),
error
),
}
}
fn prompt_for_session_state_conflict(message: &str) -> Result<SessionStateConflictChoice> {
#[cfg(test)]
if let Some(choice) = test_session_state_conflict_choice() {
return Ok(choice);
}
eprintln!("{} {}", style("!").yellow().bold(), message);
let options = vec![
SESSION_STATE_CONFLICT_ABORT_LABEL,
SESSION_STATE_CONFLICT_OVERWRITE_LABEL,
];
match inquire::Select::new("How should cruise proceed?", options).prompt() {
Ok(choice) if choice == SESSION_STATE_CONFLICT_ABORT_LABEL => {
Ok(SessionStateConflictChoice::Abort)
}
Ok(_) => Ok(SessionStateConflictChoice::Overwrite),
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {
Ok(SessionStateConflictChoice::Abort)
}
Err(e) => Err(CruiseError::Other(format!("selection error: {e}"))),
}
}
fn stdin_is_terminal() -> bool {
#[cfg(test)]
if let Ok(value) = std::env::var(TEST_STDIN_IS_TERMINAL_ENV) {
return value == "1";
}
std::io::stdin().is_terminal()
}
fn prompt_workspace_mode() -> Result<WorkspaceMode> {
#[cfg(test)]
if let Some(mode) = test_workspace_mode_choice() {
return Ok(mode);
}
if !stdin_is_terminal() {
return Ok(WorkspaceMode::Worktree);
}
let options = vec![WORKSPACE_WORKTREE_LABEL, WORKSPACE_CURRENT_BRANCH_LABEL];
match inquire::Select::new("Where should cruise execute?", options).prompt() {
Ok(choice) if choice == WORKSPACE_CURRENT_BRANCH_LABEL => Ok(WorkspaceMode::CurrentBranch),
Ok(_) => Ok(WorkspaceMode::Worktree),
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => Err(
CruiseError::Other("workspace mode selection cancelled".to_string()),
),
Err(e) => Err(CruiseError::Other(format!("selection error: {e}"))),
}
}
#[cfg(test)]
fn test_workspace_mode_choice() -> Option<WorkspaceMode> {
std::env::var(TEST_WORKSPACE_MODE_ENV)
.ok()
.and_then(|v| match v.as_str() {
"current_branch" => Some(WorkspaceMode::CurrentBranch),
"worktree" => Some(WorkspaceMode::Worktree),
_ => None,
})
}
#[cfg(test)]
fn test_session_state_conflict_choice() -> Option<SessionStateConflictChoice> {
std::env::var(TEST_STATE_CONFLICT_ACTION_ENV)
.ok()
.and_then(|value| match value.as_str() {
"abort" => Some(SessionStateConflictChoice::Abort),
"overwrite" => Some(SessionStateConflictChoice::Overwrite),
_ => None,
})
}
#[cfg(test)]
fn record_session_state_conflict_choice(choice: &str) {
if let Ok(path) = std::env::var(TEST_STATE_CONFLICT_LOG_ENV)
&& let Ok(mut file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
{
use std::io::Write;
let _ = writeln!(file, "{choice}");
}
}
fn load_run_all_result_state(
manager: &SessionManager,
fallback: &SessionState,
) -> Result<SessionState> {
let contents = manager.inspect_state_file(&fallback.id)?;
if let SessionFileContents::Parsed { state, .. } = contents {
Ok(*state)
} else {
let state_path = manager.state_path(&fallback.id);
let message = session_state_conflict_message(&state_path, &contents);
let mut state = fallback.clone();
state.phase = SessionPhase::Failed(message);
state.completed_at = Some(current_iso8601());
Ok(state)
}
}
pub async fn run(args: RunArgs) -> Result<()> {
if args.all {
if args.session.is_some() {
return Err(CruiseError::Other(
"Cannot specify both --all and a session ID".to_string(),
));
}
return run_all(args).await;
}
match run_single(args, WorkspaceOverride::RespectSession).await {
Err(CruiseError::StepPaused) => {
eprintln!("Session paused. Resume with `cruise run`.");
Ok(())
}
other => other,
}
}
#[expect(clippy::too_many_lines)]
async fn run_single(args: RunArgs, workspace_override: WorkspaceOverride) -> Result<()> {
let _current_dir_guard = CurrentDirGuard::capture()?;
let manager = SessionManager::new(get_cruise_home()?);
let session_id = args
.session
.map_or_else(|| select_pending_session(&manager), Ok)?;
let (mut session, initial_fingerprint) = manager.load_with_fingerprint(&session_id)?;
if !session.phase.is_runnable() {
return Err(CruiseError::Other(format!(
"Session {} is in '{}' phase and cannot be run. Approve it first with `cruise list`.",
session_id,
session.phase.label()
)));
}
let config = manager.load_config(&session)?;
validate_config(&config)?;
if args.dry_run {
eprintln!("{}", style(format!("Session: {session_id}")).dim());
print_dry_run(&config, session.current_step.as_deref());
return Ok(());
}
let compiled = crate::workflow::compile(config)?;
let effective_workspace_mode = match workspace_override {
WorkspaceOverride::ForceWorktree => WorkspaceMode::Worktree,
WorkspaceOverride::RespectSession => {
if session.current_step.is_none() && session.workspace_mode == WorkspaceMode::Worktree {
prompt_workspace_mode()?
} else {
session.workspace_mode
}
}
};
session.workspace_mode = effective_workspace_mode;
if effective_workspace_mode == WorkspaceMode::Worktree {
crate::worktree_pr::ensure_gh_available()?;
}
let start_step = session.current_step.clone().map_or_else(
|| {
compiled
.steps
.keys()
.next()
.ok_or_else(|| CruiseError::Other("config has no steps".to_string()))
.cloned()
},
Ok,
)?;
log_resume_message(&session);
std::env::set_current_dir(session.base_dir.clone())?;
let execution_workspace =
prepare_execution_workspace(&manager, &mut session, effective_workspace_mode)?;
log_execution_workspace(&execution_workspace);
update_session_workspace(&mut session, &execution_workspace);
session.phase = SessionPhase::Running;
let initial_fingerprint =
save_session_state_with_conflict_resolution(&manager, &session, initial_fingerprint)?;
std::env::set_current_dir(execution_workspace.path())?;
let plan_path = session.plan_path(&manager.sessions_dir());
let mut vars = VariableStore::new(session.input.clone());
vars.set_named_file(PLAN_VAR, plan_path);
let mut tracker = FileTracker::with_root(execution_workspace.path().to_path_buf());
let config_reloader: Option<Box<dyn Fn() -> Result<Option<CompiledWorkflow>>>> =
session.config_path.as_ref().map(|path| {
let path = path.clone();
let last_mtime = Cell::new(std::fs::metadata(&path).and_then(|m| m.modified()).ok());
Box::new(move || {
let current_mtime = std::fs::metadata(&path).and_then(|m| m.modified()).ok();
if current_mtime == last_mtime.get() {
return Ok(None);
}
let yaml = std::fs::read_to_string(&path).map_err(|e| {
CruiseError::Other(format!("failed to read config {}: {}", path.display(), e))
})?;
let config = crate::config::WorkflowConfig::from_yaml(&yaml)
.map_err(|e| CruiseError::ConfigParseError(e.to_string()))?;
let compiled = crate::workflow::compile(config)?;
last_mtime.set(current_mtime);
Ok(Some(compiled))
}) as Box<dyn Fn() -> Result<Option<CompiledWorkflow>>>
});
let log_path = manager.run_log_path(&session_id);
let logger = SessionLogger::new(log_path);
logger.write("--- run started ---");
let skipped_steps = session.skipped_steps.clone();
let session_cell = RefCell::new(&mut session);
let session_fingerprint = Cell::new(initial_fingerprint);
let on_step_start = |step: &str| {
logger.write(step);
let mut s = session_cell.borrow_mut();
s.current_step = Some(step.to_string());
let fingerprint =
save_session_state_with_conflict_resolution(&manager, &s, session_fingerprint.get())?;
session_fingerprint.set(fingerprint);
Ok(())
};
let cancel_token = CancellationToken::new();
let ctx = crate::engine::ExecutionContext {
compiled: &compiled,
max_retries: args.max_retries,
rate_limit_retries: args.rate_limit_retries,
on_step_start: &on_step_start,
cancel_token: Some(&cancel_token),
option_handler: &CliOptionHandler,
config_reloader: config_reloader.as_deref(),
working_dir: Some(execution_workspace.path()),
skipped_steps: &skipped_steps,
};
let exec_result = tokio::select! {
result = execute_steps(&ctx, &mut vars, &mut tracker, &start_step) => result,
_ = tokio::signal::ctrl_c() => {
cancel_token.cancel();
Err(CruiseError::Interrupted)
},
};
let session = session_cell.into_inner();
if matches!(exec_result, Err(CruiseError::Interrupted)) {
logger.write("|| cancelled");
eprintln!(
"\n{} Interrupted -- session saved as Suspended.",
style("||").yellow().bold()
);
session.phase = SessionPhase::Suspended;
manager.save(session)?;
return Err(CruiseError::Interrupted);
}
let overall_result = match exec_result {
Ok(exec) => {
logger.write(&format!(
"v completed -- run: {}, skipped: {}, failed: {}",
exec.run, exec.skipped, exec.failed
));
match &execution_workspace {
ExecutionWorkspace::CurrentBranch { .. } => Ok(()),
ExecutionWorkspace::Worktree { ctx, .. } => {
crate::worktree_pr::handle_worktree_pr(
ctx,
&compiled,
&mut vars,
&mut tracker,
session,
args.rate_limit_retries,
args.max_retries,
)
.await
}
}
}
Err(e) => {
logger.write(&format!("x failed: {}", e.detailed_message()));
Err(e)
}
};
if let Err(e) = &overall_result
&& matches!(
e,
CruiseError::SessionStateConflict(_) | CruiseError::SessionStateConflictAborted(_)
)
{
return overall_result;
}
apply_run_result_to_session(session, &overall_result);
save_session_state_with_conflict_resolution(&manager, session, session_fingerprint.get())?;
overall_result
}
fn apply_run_result_to_session(session: &mut SessionState, result: &Result<()>) {
match result {
Ok(()) => {
session.phase = SessionPhase::Completed;
session.completed_at = Some(current_iso8601());
}
Err(CruiseError::StepPaused) => {
}
Err(e) => {
session.phase = SessionPhase::Failed(e.to_string());
session.completed_at = Some(current_iso8601());
}
}
}
fn log_resume_message(session: &SessionState) {
let Some(ref step) = session.current_step else {
return;
};
match &session.phase {
SessionPhase::Running | SessionPhase::Suspended => {
eprintln!("{} Resuming from step: {}", style("R").cyan(), step);
}
SessionPhase::Failed(_) => {
eprintln!(
"{} Retrying from failed step: {}",
style("R").yellow(),
step
);
}
_ => {}
}
}
fn log_execution_workspace(ws: &ExecutionWorkspace) {
match ws {
ExecutionWorkspace::Worktree { ctx, reused } => {
let suffix = if *reused { " (reused)" } else { "" };
eprintln!(
"{} worktree: {}{}",
style("->").cyan(),
ctx.path.display(),
suffix
);
}
ExecutionWorkspace::CurrentBranch { path } => {
eprintln!("{} current branch: {}", style("->").cyan(), path.display());
}
}
}
async fn run_all(args: RunArgs) -> Result<()> {
let manager = SessionManager::new(get_cruise_home()?);
let mut seen: HashSet<String> = HashSet::new();
let mut results: Vec<SessionState> = Vec::new();
loop {
let remaining = manager.run_all_remaining(&seen)?;
let Some(session) = remaining.into_iter().next() else {
break;
};
seen.insert(session.id.clone());
let session_args = RunArgs {
session: Some(session.id.clone()),
all: false,
max_retries: args.max_retries,
rate_limit_retries: args.rate_limit_retries,
dry_run: args.dry_run,
};
let run_result = Box::pin(run_single(session_args, WorkspaceOverride::ForceWorktree)).await;
let interrupted = matches!(run_result, Err(CruiseError::Interrupted));
match run_result {
Err(CruiseError::StepPaused) => {
eprintln!("session {} paused by user", session.id);
}
Err(e) if !interrupted => {
eprintln!(
"warning: session {} encountered an error: {}",
session.id,
e.detailed_message()
);
}
Ok(()) | Err(_) => {}
}
results.push(load_run_all_result_state(&manager, &session)?);
if interrupted {
break;
}
}
let summary = format_run_all_summary(&results);
if !summary.is_empty() {
eprintln!("\n{summary}");
}
Ok(())
}
fn select_pending_session(manager: &SessionManager) -> Result<String> {
let pending = manager.pending()?;
if pending.is_empty() {
return Err(CruiseError::Other(
"No pending sessions. Run `cruise plan` first.".to_string(),
));
}
if pending.len() == 1 {
let s = &pending[0];
eprintln!(
"{} Selected session: {} -- {}",
style("->").cyan(),
s.id,
crate::display::truncate(&s.input, 60)
);
return Ok(s.id.clone());
}
let labels: Vec<String> = pending
.iter()
.map(|s| {
format!(
"{} | {} | {}",
s.id,
s.phase.label(),
crate::display::truncate(&s.input, 60)
)
})
.collect();
let label_refs: Vec<&str> = labels.iter().map(std::string::String::as_str).collect();
let selected = match inquire::Select::new("Select a session to run:", label_refs).prompt() {
Ok(s) => s,
Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {
return Err(CruiseError::Other(
"session selection cancelled".to_string(),
));
}
Err(e) => return Err(CruiseError::Other(format!("selection error: {e}"))),
};
let idx = labels
.iter()
.position(|l| l.as_str() == selected)
.ok_or_else(|| CruiseError::Other(format!("selected session not found: {selected}")))?;
Ok(pending[idx].id.clone())
}
fn format_run_all_summary(results: &[SessionState]) -> String {
const MAX_INPUT_CHARS: usize = 60;
if results.is_empty() {
return String::new();
}
let mut lines = Vec::with_capacity(results.len() + 1);
lines.push(format!(
"=== Run All Summary ({} sessions) ===",
results.len()
));
for (i, result) in results.iter().enumerate() {
let truncated = crate::display::truncate(&result.input, MAX_INPUT_CHARS);
let line = match &result.phase {
SessionPhase::Completed => {
let pr = result
.pr_url
.as_deref()
.map(|url| format!(" {} {url}", style("->").yellow()))
.unwrap_or_default();
format!(
"[{}] {} {}{}",
i + 1,
style("v").green().bold(),
truncated,
pr
)
}
SessionPhase::Failed(err) => {
format!(
"[{}] {} {} -- Failed: {}",
i + 1,
style("x").red().bold(),
truncated,
err
)
}
SessionPhase::Suspended => {
format!(
"[{}] {} {} -- Suspended",
i + 1,
style("||").yellow().bold(),
truncated
)
}
SessionPhase::AwaitingApproval | SessionPhase::Planned | SessionPhase::Running => {
format!("[{}] ? {}", i + 1, truncated)
}
};
lines.push(line);
}
lines.join("\n")
}
#[cfg(test)]
#[cfg(unix)]
mod tests {
use super::*;
use crate::cli::{DEFAULT_MAX_RETRIES, DEFAULT_RATE_LIMIT_RETRIES};
use crate::session::WorkspaceMode;
use crate::worktree_pr::{
CommitOutcome, PrAttemptOutcome, attempt_pr_creation, build_pr_prompt,
extract_last_path_segment, parse_pr_metadata, strip_code_block,
};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use tempfile::TempDir;
use crate::test_binary_support::PathEnvGuard;
use crate::test_support::{init_git_repo, run_git_ok};
use crate::worktree;
fn git_stdout_ok(dir: &Path, args: &[&str]) -> String {
let output = Command::new("git")
.args(args)
.current_dir(dir)
.output()
.unwrap_or_else(|e| panic!("git command failed to start: {e}"));
assert!(
output.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&output.stderr).trim()
);
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
fn create_worktree(tmp: &TempDir, session_id: &str) -> (PathBuf, worktree::WorktreeContext) {
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")),
],
);
let worktrees_dir = tmp.path().join("worktrees");
let (ctx, reused) =
worktree::setup_session_worktree(&repo, session_id, "test task", &worktrees_dir, None)
.unwrap_or_else(|e| panic!("{e:?}"));
assert!(!reused, "test worktree should be created fresh");
(repo, ctx)
}
fn install_fake_gh(bin_dir: &Path, log_path: &Path, head_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\nprintf '%s\\n' \"$*\" >> \"{}\"\ngit rev-parse HEAD > \"{}\"\nprintf '%s\\n' \"{}\"\n",
log_path.display(),
head_path.display(),
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:?}"));
}
#[cfg(windows)]
{
let script_path = bin_dir.join("gh.cmd");
let script = format!(
"@echo off\r\necho %*>>\"{}\"\r\ngit rev-parse HEAD > \"{}\"\r\necho {}\r\n",
log_path.display(),
head_path.display(),
url
);
fs::write(&script_path, script).unwrap();
}
}
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:?}"));
}
#[cfg(windows)]
{
let script_path = bin_dir.join("gh.cmd");
let script = format!(
"@echo off\r\nif \"%1\"==\"--version\" (\r\n echo gh version test\r\n exit /b 0\r\n)\r\necho %*>>\"{}\"\r\nif \"%1\"==\"pr\" if \"%2\"==\"create\" echo {}\r\nif \"%1\"==\"pr\" if \"%2\"==\"view\" echo {}\r\n",
log_path.display(),
url,
url
);
fs::write(&script_path, script).unwrap();
}
}
struct ProcessStateGuard {
prev_home: Option<std::ffi::OsString>,
prev_userprofile: Option<std::ffi::OsString>,
prev_path: Option<std::ffi::OsString>,
prev_dir: PathBuf,
extra_env: Vec<(String, Option<std::ffi::OsString>)>,
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_dir = std::env::current_dir().unwrap_or_else(|_| fallback_root());
unsafe {
std::env::set_var("HOME", home);
std::env::set_var("USERPROFILE", home);
}
Self {
prev_home,
prev_userprofile,
prev_path,
prev_dir,
extra_env: Vec::new(),
lock,
}
}
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) };
}
}
fn set_current_dir(&self, dir: &Path) {
let _ = &self.lock;
let _ = std::env::set_current_dir(dir);
}
fn set_env(&mut self, key: &str, value: impl AsRef<std::ffi::OsStr>) {
self.remember_env(key);
unsafe {
std::env::set_var(key, value);
}
}
fn remove_env(&mut self, key: &str) {
self.remember_env(key);
unsafe {
std::env::remove_var(key);
}
}
fn remember_env(&mut self, key: &str) {
if self.extra_env.iter().all(|(existing, _)| existing != key) {
self.extra_env
.push((key.to_string(), std::env::var_os(key)));
}
}
}
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 {
for (key, previous) in self.extra_env.iter().rev() {
if let Some(value) = previous {
std::env::set_var(key, value);
} else {
std::env::remove_var(key);
}
}
if let Some(ref prev_home) = self.prev_home {
std::env::set_var("HOME", prev_home);
} else {
std::env::remove_var("HOME");
}
if let Some(ref prev_userprofile) = self.prev_userprofile {
std::env::set_var("USERPROFILE", prev_userprofile);
} else {
std::env::remove_var("USERPROFILE");
}
if let Some(ref prev_path) = self.prev_path {
std::env::set_var("PATH", prev_path);
} else {
std::env::remove_var("PATH");
}
}
}
}
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 make_current_branch_session(
id: &str,
repo: &Path,
input: &str,
target_branch: &str,
) -> SessionState {
let mut session = SessionState::new(
id.to_string(),
repo.to_path_buf(),
"cruise.yaml".to_string(),
input.to_string(),
);
session.phase = SessionPhase::Planned;
session.workspace_mode = WorkspaceMode::CurrentBranch;
session.target_branch = Some(target_branch.to_string());
session
}
fn write_config(manager: &SessionManager, session_id: &str, yaml: &str) {
let session_dir = manager.sessions_dir().join(session_id);
fs::create_dir_all(&session_dir).unwrap_or_else(|e| panic!("{e:?}"));
fs::write(session_dir.join("config.yaml"), yaml).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 run_args(session_id: &str) -> RunArgs {
RunArgs {
session: Some(session_id.to_string()),
all: false,
max_retries: 10,
rate_limit_retries: 0,
dry_run: false,
}
}
fn blocking_conflict_config() -> String {
r"command:
- cat
steps:
first:
command: |
while [ ! -f proceed.txt ]; do sleep 0.05; done
second:
command: |
printf second > second.txt
"
.to_string()
}
fn setup_current_branch_conflict_session(
tmp: &TempDir,
session_id: &str,
input: &str,
) -> (ProcessStateGuard, PathBuf, SessionManager) {
let process = ProcessStateGuard::new(tmp.path());
let repo = create_repo_with_origin(tmp);
process.set_current_dir(&repo);
let manager = SessionManager::new(get_cruise_home().unwrap_or_else(|e| panic!("{e:?}")));
let session = make_current_branch_session(session_id, &repo, input, "main");
manager.create(&session).unwrap_or_else(|e| panic!("{e:?}"));
write_config(&manager, session_id, &blocking_conflict_config());
(process, repo, manager)
}
fn configure_conflict_test_env(
process: &mut ProcessStateGuard,
is_terminal: bool,
action: Option<&str>,
log_path: &Path,
) {
process.set_env(
TEST_STDIN_IS_TERMINAL_ENV,
if is_terminal { "1" } else { "0" },
);
if let Some(action) = action {
process.set_env(TEST_STATE_CONFLICT_ACTION_ENV, action);
} else {
process.remove_env(TEST_STATE_CONFLICT_ACTION_ENV);
}
process.set_env(TEST_STATE_CONFLICT_LOG_ENV, log_path);
}
async fn wait_for_session_step(manager: &SessionManager, session_id: &str, step: &str) {
for _ in 0..200 {
if let Ok(state) = manager.load(session_id)
&& matches!(state.phase, SessionPhase::Running)
&& state.current_step.as_deref() == Some(step)
{
return;
}
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
}
panic!("timed out waiting for session {session_id} to reach step {step}");
}
async fn mutate_state_after_first_step<F, G>(
manager: &SessionManager,
session_id: &str,
workspace_path: G,
mutate: F,
) where
F: FnOnce(&SessionManager, &str),
G: FnOnce(&SessionState) -> PathBuf,
{
wait_for_session_step(manager, session_id, "first").await;
let state = manager.load(session_id).unwrap_or_else(|e| panic!("{e:?}"));
let workspace = workspace_path(&state);
mutate(manager, session_id);
fs::write(workspace.join("proceed.txt"), "go").unwrap_or_else(|e| panic!("{e:?}"));
}
fn write_external_failed_state(manager: &SessionManager, session_id: &str) {
let mut external = manager.load(session_id).unwrap_or_else(|e| panic!("{e:?}"));
external.phase = SessionPhase::Failed("external edit".to_string());
external.current_step = Some("external-step".to_string());
manager.save(&external).unwrap_or_else(|e| panic!("{e:?}"));
}
fn make_pr_prompt_config(pr_language_yaml: Option<&str>) -> CompiledWorkflow {
let mut yaml = String::from("command: [claude, -p]\n");
if let Some(pr_language_yaml) = pr_language_yaml {
yaml.push_str(pr_language_yaml);
}
yaml.push_str("steps:\n implement:\n prompt: test\n");
let config =
crate::config::WorkflowConfig::from_yaml(&yaml).unwrap_or_else(|e| panic!("{e:?}"));
crate::workflow::compile(config).unwrap_or_else(|e| panic!("{e:?}"))
}
fn make_pr_prompt_vars() -> VariableStore {
let mut vars = VariableStore::new("test input".to_string());
vars.set_named_file(PLAN_VAR, PathBuf::from("plan.md"));
vars
}
#[test]
fn test_extract_last_path_segment_github_pr_url() {
let url = "https://github.com/owner/repo/pull/42";
let result = extract_last_path_segment(url);
assert_eq!(result, Some("42".to_string()));
}
#[test]
fn test_build_pr_prompt_includes_configured_pr_language() {
let config = make_pr_prompt_config(Some("pr_language: Japanese\n"));
let mut vars = make_pr_prompt_vars();
let prompt = build_pr_prompt(&mut vars, &config).unwrap_or_else(|e| panic!("{e:?}"));
assert!(
prompt.contains("Japanese"),
"prompt should include configured language: {prompt}"
);
assert!(
prompt.contains("plan.md"),
"prompt should continue resolving existing variables: {prompt}"
);
}
#[test]
fn test_build_pr_prompt_defaults_blank_pr_language_to_english() {
let config = make_pr_prompt_config(Some("pr_language: \" \"\n"));
let mut vars = make_pr_prompt_vars();
let prompt = build_pr_prompt(&mut vars, &config).unwrap_or_else(|e| panic!("{e:?}"));
assert!(
prompt.contains(crate::config::DEFAULT_PR_LANGUAGE),
"prompt should include the default language: {prompt}"
);
}
#[test]
fn test_attempt_pr_creation_skips_gh_when_branch_has_no_commits() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let (_repo, ctx) = create_worktree(&tmp, "20260307225900");
let bin_dir = tmp.path().join("bin");
let log_path = tmp.path().join("gh.log");
let head_path = tmp.path().join("gh-head.txt");
install_fake_gh(
&bin_dir,
&log_path,
&head_path,
"https://github.com/owner/repo/pull/1",
);
let _path_guard = PathEnvGuard::prepend(&bin_dir);
let result =
attempt_pr_creation(&ctx, "test task", "", "").unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(result, PrAttemptOutcome::SkippedNoCommits);
assert!(
!log_path.exists(),
"gh should not be called when no commit exists"
);
assert!(
!head_path.exists(),
"gh should not observe HEAD when skipped"
);
worktree::cleanup_worktree(&ctx).unwrap_or_else(|e| panic!("{e:?}"));
}
#[test]
fn test_attempt_pr_creation_commits_changes_before_calling_gh() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let f = setup_pr_test(
&tmp,
"20260307225901",
"https://github.com/owner/repo/pull/2",
);
let base_head = git_stdout_ok(&f.repo, &["rev-parse", "HEAD"]);
let result =
attempt_pr_creation(&f.ctx, "add feature", "", "").unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(
result,
PrAttemptOutcome::Created {
url: f.url.clone(),
commit_outcome: CommitOutcome::Created,
}
);
assert_eq!(
git_stdout_ok(&f.ctx.path, &["log", "-1", "--pretty=%s"]),
"add feature"
);
let worktree_head = git_stdout_ok(&f.ctx.path, &["rev-parse", "HEAD"]);
assert_ne!(
worktree_head, base_head,
"helper should create a new commit"
);
assert_eq!(
fs::read_to_string(&f.head_path)
.unwrap_or_else(|e| panic!("{e:?}"))
.trim(),
worktree_head
);
let gh_args = fs::read_to_string(&f.log_path).unwrap_or_else(|e| panic!("{e:?}"));
assert!(
gh_args.contains("pr create --head") && gh_args.contains("--fill"),
"fake gh should receive a pr create invocation, got: {gh_args}"
);
assert!(
gh_args.contains("--draft"),
"gh pr create should include --draft flag, got: {gh_args}"
);
worktree::cleanup_worktree(&f.ctx).unwrap_or_else(|e| panic!("{e:?}"));
}
#[test]
fn test_attempt_pr_creation_reuses_existing_branch_commits() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let f = setup_pr_test(
&tmp,
"20260307225902",
"https://github.com/owner/repo/pull/3",
);
let base_head = git_stdout_ok(&f.repo, &["rev-parse", "HEAD"]);
run_git_ok(&f.ctx.path, &["add", "."]);
run_git_ok(&f.ctx.path, &["commit", "-m", "existing commit"]);
let existing_head = git_stdout_ok(&f.ctx.path, &["rev-parse", "HEAD"]);
assert_ne!(existing_head, base_head);
let result = attempt_pr_creation(&f.ctx, "rerun without changes", "", "")
.unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(
result,
PrAttemptOutcome::Created {
url: f.url.clone(),
commit_outcome: CommitOutcome::NoChanges,
}
);
assert_eq!(
git_stdout_ok(&f.ctx.path, &["rev-parse", "HEAD"]),
existing_head
);
assert_eq!(
fs::read_to_string(&f.head_path)
.unwrap_or_else(|e| panic!("{e:?}"))
.trim(),
existing_head
);
worktree::cleanup_worktree(&f.ctx).unwrap_or_else(|e| panic!("{e:?}"));
}
struct PrTestFixture {
repo: PathBuf,
ctx: worktree::WorktreeContext,
#[expect(dead_code)]
path_guard: PathEnvGuard,
log_path: PathBuf,
head_path: PathBuf,
url: String,
}
fn setup_pr_test(tmp: &TempDir, session_id: &str, url: &str) -> PrTestFixture {
let (repo, ctx) = create_worktree(tmp, session_id);
fs::write(ctx.path.join("feature.txt"), "hello").unwrap_or_else(|e| panic!("{e:?}"));
let bin_dir = tmp.path().join("bin");
let log_path = tmp.path().join("gh.log");
let head_path = tmp.path().join("gh-head.txt");
install_fake_gh(&bin_dir, &log_path, &head_path, url);
let path_guard = PathEnvGuard::prepend(&bin_dir);
PrTestFixture {
repo,
ctx,
path_guard,
log_path,
head_path,
url: url.to_string(),
}
}
#[test]
fn test_attempt_pr_creation_uses_pr_title_as_commit_message_when_title_is_present() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let f = setup_pr_test(
&tmp,
"20260310pr_title_commit_01",
"https://github.com/owner/repo/pull/10",
);
let pr_title = "feat: add user icon registration";
let result = attempt_pr_creation(&f.ctx, "implement user icon feature", pr_title, "")
.unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(
result,
PrAttemptOutcome::Created {
url: f.url.clone(),
commit_outcome: CommitOutcome::Created
}
);
assert_eq!(
git_stdout_ok(&f.ctx.path, &["log", "-1", "--pretty=%s"]),
pr_title,
"commit subject should equal the PR title when title is non-empty"
);
let gh_args = fs::read_to_string(&f.log_path).unwrap_or_else(|e| panic!("{e:?}"));
assert!(
gh_args.contains("--title") && gh_args.contains(pr_title),
"fake gh should receive --title with the PR title; got: {gh_args}"
);
worktree::cleanup_worktree(&f.ctx).unwrap_or_else(|e| panic!("{e:?}"));
}
#[test]
fn test_attempt_pr_creation_falls_back_to_message_when_pr_title_is_empty() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let f = setup_pr_test(
&tmp,
"20260310pr_title_commit_02",
"https://github.com/owner/repo/pull/11",
);
let fallback = "implement user icon feature";
let result =
attempt_pr_creation(&f.ctx, fallback, "", "").unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(
result,
PrAttemptOutcome::Created {
url: f.url.clone(),
commit_outcome: CommitOutcome::Created
}
);
assert_eq!(
git_stdout_ok(&f.ctx.path, &["log", "-1", "--pretty=%s"]),
fallback,
"commit subject should equal the fallback message when PR title is empty"
);
let gh_args = fs::read_to_string(&f.log_path).unwrap_or_else(|e| panic!("{e:?}"));
assert!(
gh_args.contains("--fill"),
"fake gh should receive --fill when PR title is empty; got: {gh_args}"
);
worktree::cleanup_worktree(&f.ctx).unwrap_or_else(|e| panic!("{e:?}"));
}
#[test]
fn test_attempt_pr_creation_treats_whitespace_only_title_as_empty() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let f = setup_pr_test(
&tmp,
"20260310pr_title_commit_03",
"https://github.com/owner/repo/pull/12",
);
let fallback = "implement user icon feature";
let result =
attempt_pr_creation(&f.ctx, fallback, " ", "").unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(
result,
PrAttemptOutcome::Created {
url: f.url.clone(),
commit_outcome: CommitOutcome::Created
}
);
assert_eq!(
git_stdout_ok(&f.ctx.path, &["log", "-1", "--pretty=%s"]),
fallback,
"whitespace-only title should be treated as empty and use fallback message"
);
let gh_args = fs::read_to_string(&f.log_path).unwrap_or_else(|e| panic!("{e:?}"));
assert!(
gh_args.contains("--fill"),
"fake gh should receive --fill when PR title is whitespace-only; got: {gh_args}"
);
worktree::cleanup_worktree(&f.ctx).unwrap_or_else(|e| panic!("{e:?}"));
}
#[test]
fn test_parse_pr_metadata_standard_frontmatter() {
let output = r#"---
title: "feat: Add user icon registration feature"
---
## Overview
Enabled users to upload icon images.
## Background
Previously, emojis were used as user icons."#;
let (title, body) = parse_pr_metadata(output);
assert_eq!(title, "feat: Add user icon registration feature");
assert_eq!(
body.trim(),
"## Overview\nEnabled users to upload icon images.\n\n## Background\nPreviously, emojis were used as user icons."
);
}
#[test]
fn test_parse_pr_metadata_wrapped_in_markdown_code_block() {
let output =
"```md\n---\ntitle: \"fix: Resolve login bug\"\n---\nFixed the login issue.\n```";
let (title, body) = parse_pr_metadata(output);
assert_eq!(title, "fix: Resolve login bug");
assert_eq!(body.trim(), "Fixed the login issue.");
}
#[test]
fn test_parse_pr_metadata_title_without_quotes() {
let output = "---\ntitle: feat: Add feature without quotes\n---\nBody text here.";
let (title, body) = parse_pr_metadata(output);
assert_eq!(title, "feat: Add feature without quotes");
assert_eq!(body.trim(), "Body text here.");
}
#[test]
fn test_parse_pr_metadata_no_frontmatter_returns_empty() {
let output = "This is just a plain text response without frontmatter.";
let (title, body) = parse_pr_metadata(output);
assert_eq!(title, "");
assert_eq!(body, "");
}
#[test]
fn test_parse_pr_metadata_missing_title_field_returns_empty() {
let output = "---\nauthor: someone\n---\nBody without title.";
let (title, body) = parse_pr_metadata(output);
assert_eq!(title, "");
assert_eq!(body, "");
}
#[test]
fn test_parse_pr_metadata_empty_body_after_frontmatter() {
let output = "---\ntitle: \"chore: Update deps\"\n---\n";
let (title, body) = parse_pr_metadata(output);
assert_eq!(title, "chore: Update deps");
assert_eq!(body.trim(), "");
}
#[test]
fn test_parse_pr_metadata_only_closing_delimiter_missing_returns_empty() {
let output = "---\ntitle: \"feat: something\"\nBody without closing delimiter.";
let (title, body) = parse_pr_metadata(output);
assert_eq!(title, "");
assert_eq!(body, "");
}
#[test]
fn test_parse_pr_metadata_wrapped_in_plain_code_block() {
let output = "```\n---\ntitle: \"docs: Update README\"\n---\nUpdated documentation.\n```";
let (title, body) = parse_pr_metadata(output);
assert_eq!(title, "docs: Update README");
assert_eq!(body.trim(), "Updated documentation.");
}
#[test]
fn test_parse_pr_metadata_with_preamble_then_frontmatter() {
let output = "Here is the PR information:\n---\ntitle: \"feat: Add new feature\"\n---\nThis adds a new feature.";
let (title, body) = parse_pr_metadata(output);
assert_eq!(title, "feat: Add new feature");
assert_eq!(body.trim(), "This adds a new feature.");
}
#[test]
fn test_parse_pr_metadata_with_preamble_then_code_block() {
let output = "Here is the PR information:\n```md\n---\ntitle: \"fix: Fix the bug\"\n---\nFixed the critical bug.\n```";
let (title, body) = parse_pr_metadata(output);
assert_eq!(title, "fix: Fix the bug");
assert_eq!(body.trim(), "Fixed the critical bug.");
}
#[test]
fn test_parse_pr_metadata_with_multiline_preamble() {
let output = "I have reviewed the changes.\nBased on the commits, here is the PR:\n\n---\ntitle: \"refactor: Clean up code\"\n---\nRefactored the core module.";
let (title, body) = parse_pr_metadata(output);
assert_eq!(title, "refactor: Clean up code");
assert_eq!(body.trim(), "Refactored the core module.");
}
#[test]
fn test_parse_pr_metadata_heading_format() {
let output = "# feat: Add user icon registration feature\n## Overview\nEnabled users to upload icon images.";
let (title, body) = parse_pr_metadata(output);
assert_eq!(title, "feat: Add user icon registration feature");
assert_eq!(
body.trim(),
"## Overview\nEnabled users to upload icon images."
);
}
#[test]
fn test_parse_pr_metadata_heading_format_in_code_block() {
let output = "```md\n# fix: Resolve login bug\nFixed the login issue.\n```";
let (title, body) = parse_pr_metadata(output);
assert_eq!(title, "fix: Resolve login bug");
assert_eq!(body.trim(), "Fixed the login issue.");
}
#[test]
fn test_parse_pr_metadata_empty_input_returns_empty() {
let (title, body) = parse_pr_metadata("");
assert_eq!(title, "");
assert_eq!(body, "");
}
#[test]
fn test_parse_pr_metadata_whitespace_only_returns_empty() {
let (title, body) = parse_pr_metadata(" \n \n ");
assert_eq!(title, "");
assert_eq!(body, "");
}
#[test]
fn test_strip_code_block_with_preamble() {
let input = "Some intro text\n```\n---\ntitle: test\n---\nbody\n```";
let result = strip_code_block(input);
assert_eq!(result.trim(), "---\ntitle: test\n---\nbody");
}
#[test]
fn test_strip_code_block_no_code_block_unchanged() {
let input = "Just plain text here.";
let result = strip_code_block(input);
assert_eq!(result, "Just plain text here.");
}
#[tokio::test]
async fn test_run_all_errors_when_session_and_all_both_specified() {
let args = RunArgs {
session: Some("some-session-id".to_string()),
all: true,
max_retries: DEFAULT_MAX_RETRIES,
rate_limit_retries: DEFAULT_RATE_LIMIT_RETRIES,
dry_run: false,
};
let result = run(args).await;
assert!(result.is_err(), "expected error but got Ok");
let msg = result
.map_or_else(|e| e, |v| panic!("expected Err, got Ok({v:?})"))
.to_string();
assert!(
msg.contains("Cannot specify both --all and a session ID"),
"unexpected error message: {msg}"
);
}
#[tokio::test]
async fn test_run_all_returns_ok_when_no_planned_sessions() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let cruise_home = tmp.path().join(".cruise");
std::fs::create_dir_all(cruise_home.join("sessions")).unwrap_or_else(|e| panic!("{e:?}"));
let orig_home = {
let _guard = crate::test_support::lock_process();
let orig = std::env::var("HOME").ok();
unsafe {
std::env::set_var("HOME", tmp.path());
}
orig
};
let args = RunArgs {
session: None,
all: true,
max_retries: DEFAULT_MAX_RETRIES,
rate_limit_retries: DEFAULT_RATE_LIMIT_RETRIES,
dry_run: false,
};
let result = run(args).await;
{
let _guard = crate::test_support::lock_process();
unsafe {
match orig_home {
Some(h) => std::env::set_var("HOME", h),
None => std::env::remove_var("HOME"),
}
}
}
assert!(result.is_ok(), "expected Ok but got: {:?}", result.err());
}
#[tokio::test(flavor = "current_thread")]
async fn test_run_current_branch_mode_keeps_changes_in_base_repo_and_skips_pr_flow() {
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(get_cruise_home().unwrap_or_else(|e| panic!("{e:?}")));
let session_id = "20260309120000";
let session = make_current_branch_session(session_id, &repo, "edit in place", "main");
manager.create(&session).unwrap_or_else(|e| panic!("{e:?}"));
write_config(
&manager,
session_id,
&single_command_config("edit", "printf direct > current-branch.txt"),
);
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(run_args(session_id)).await;
assert!(
result.is_ok(),
"expected current-branch mode to succeed: {result:?}"
);
let loaded = manager.load(session_id).unwrap_or_else(|e| panic!("{e:?}"));
assert!(
loaded.worktree_path.is_none(),
"current-branch mode should not persist a worktree path"
);
assert!(
loaded.worktree_branch.is_none(),
"current-branch mode should not persist a worktree branch"
);
assert!(
loaded.pr_url.is_none(),
"current-branch mode should skip PR creation"
);
assert!(
repo.join("current-branch.txt").exists(),
"current-branch mode should write changes into the base repository"
);
assert_eq!(
fs::read_to_string(repo.join("current-branch.txt")).unwrap_or_else(|e| panic!("{e:?}")),
"direct"
);
assert!(
git_stdout_ok(&repo, &["status", "--short"]).contains("current-branch.txt"),
"current-branch mode should leave the new file uncommitted in the base repository"
);
assert!(
!manager.worktrees_dir().join(session_id).exists(),
"current-branch mode should not create a cruise worktree directory"
);
assert!(
!gh_log.exists(),
"current-branch mode should not invoke gh at all"
);
}
#[tokio::test(flavor = "current_thread")]
async fn test_run_current_branch_mode_errors_when_branch_has_changed() {
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(get_cruise_home().unwrap_or_else(|e| panic!("{e:?}")));
let session_id = "20260309121000";
let session =
make_current_branch_session(session_id, &repo, "stay on planned branch", "main");
manager.create(&session).unwrap_or_else(|e| panic!("{e:?}"));
write_config(
&manager,
session_id,
&single_command_config("edit", "printf nope > wrong-branch.txt"),
);
run_git_ok(&repo, &["checkout", "-b", "other-branch"]);
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/100");
process.prepend_path(&bin_dir);
let result = run(run_args(session_id)).await;
assert!(
result.is_err(),
"expected current-branch mode to reject a branch mismatch"
);
let message = result
.map_or_else(|e| e, |v| panic!("expected Err, got Ok({v:?})"))
.to_string();
assert!(message.contains("branch"), "unexpected error: {message}");
assert!(message.contains("main"), "unexpected error: {message}");
assert!(
message.contains("other-branch"),
"unexpected error: {message}"
);
}
#[tokio::test(flavor = "current_thread")]
async fn test_run_current_branch_mode_errors_when_working_tree_is_dirty() {
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(get_cruise_home().unwrap_or_else(|e| panic!("{e:?}")));
let session_id = "20260309122000";
let session = make_current_branch_session(session_id, &repo, "edit dirty tree", "main");
manager.create(&session).unwrap_or_else(|e| panic!("{e:?}"));
write_config(
&manager,
session_id,
&single_command_config("edit", "printf more > new-file.txt"),
);
fs::write(repo.join("already-dirty.txt"), "dirty").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/101");
process.prepend_path(&bin_dir);
let result = run(run_args(session_id)).await;
assert!(
result.is_err(),
"expected current-branch mode to reject a dirty working tree"
);
let message = result
.map_or_else(|e| e, |v| panic!("expected Err, got Ok({v:?})"))
.to_string();
assert!(message.contains("dirty"), "unexpected error: {message}");
}
#[tokio::test(flavor = "current_thread")]
async fn test_run_current_branch_mode_errors_on_detached_head() {
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(get_cruise_home().unwrap_or_else(|e| panic!("{e:?}")));
let session_id = "20260309123000";
let session = make_current_branch_session(session_id, &repo, "edit detached head", "main");
manager.create(&session).unwrap_or_else(|e| panic!("{e:?}"));
write_config(
&manager,
session_id,
&single_command_config("edit", "printf nope > detached.txt"),
);
run_git_ok(&repo, &["checkout", "--detach"]);
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/102");
process.prepend_path(&bin_dir);
let result = run(run_args(session_id)).await;
assert!(
result.is_err(),
"expected current-branch mode to reject detached HEAD"
);
let message = result
.map_or_else(|e| e, |v| panic!("expected Err, got Ok({v:?})"))
.to_string();
assert!(message.contains("detached"), "unexpected error: {message}");
}
#[tokio::test(flavor = "current_thread")]
async fn test_run_current_branch_mode_resumes_from_saved_step_without_pr_flow() {
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(get_cruise_home().unwrap_or_else(|e| panic!("{e:?}")));
let session_id = "20260309124000";
let mut session = make_current_branch_session(session_id, &repo, "resume in place", "main");
session.phase = SessionPhase::Running;
session.current_step = Some("second".to_string());
manager.create(&session).unwrap_or_else(|e| panic!("{e:?}"));
write_config(
&manager,
session_id,
r"command:
- cat
steps:
first:
command: |
printf first > first.txt
second:
command: |
printf second > second.txt
",
);
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/103");
process.prepend_path(&bin_dir);
let result = run(run_args(session_id)).await;
assert!(
result.is_ok(),
"expected current-branch resume to succeed: {result:?}"
);
let loaded = manager.load(session_id).unwrap_or_else(|e| panic!("{e:?}"));
assert!(
loaded.worktree_path.is_none(),
"current-branch resume should not persist a worktree path"
);
assert!(
loaded.pr_url.is_none(),
"current-branch resume should skip PR creation"
);
assert!(
!repo.join("first.txt").exists(),
"resume should continue from the saved step instead of rerunning earlier steps"
);
assert!(
repo.join("second.txt").exists(),
"resume should execute the saved current step in the base repository"
);
assert!(
!gh_log.exists(),
"current-branch resume should not invoke gh"
);
}
#[tokio::test(flavor = "current_thread")]
async fn test_run_current_branch_conflict_overwrite_continues_and_logs_choice() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let session_id = "20260310140000";
let (mut process, repo, manager) =
setup_current_branch_conflict_session(&tmp, session_id, "overwrite external state");
let log_path = tmp.path().join("conflict-overwrite.log");
configure_conflict_test_env(&mut process, true, Some("overwrite"), &log_path);
let run_fut = run(run_args(session_id));
let mutate_fut = mutate_state_after_first_step(
&manager,
session_id,
|_| repo.clone(),
write_external_failed_state,
);
let (result, ()) = tokio::join!(run_fut, mutate_fut);
assert!(
result.is_ok(),
"overwrite choice should allow the run to continue: {result:?}"
);
let loaded = manager.load(session_id).unwrap_or_else(|e| panic!("{e:?}"));
assert!(matches!(loaded.phase, SessionPhase::Completed));
assert_eq!(loaded.current_step.as_deref(), Some("second"));
assert!(repo.join("second.txt").exists());
let log = fs::read_to_string(&log_path).unwrap_or_else(|e| {
panic!("conflict resolution should be logged for overwrite tests: {e:?}")
});
assert!(
log.contains("overwrite"),
"expected overwrite decision in log, got: {log}"
);
}
#[tokio::test(flavor = "current_thread")]
async fn test_run_current_branch_conflict_abort_preserves_external_state() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let session_id = "20260310140001";
let (mut process, repo, manager) =
setup_current_branch_conflict_session(&tmp, session_id, "abort on conflict");
let log_path = tmp.path().join("conflict-abort.log");
configure_conflict_test_env(&mut process, true, Some("abort"), &log_path);
let run_fut = run(run_args(session_id));
let mutate_fut = mutate_state_after_first_step(
&manager,
session_id,
|_| repo.clone(),
write_external_failed_state,
);
let (result, ()) = tokio::join!(run_fut, mutate_fut);
match result {
Err(CruiseError::SessionStateConflictAborted(message)) => {
assert!(
message.contains("state.json"),
"abort message should mention state.json: {message}"
);
}
other => panic!("expected SessionStateConflictAborted, got {other:?}"),
}
let loaded = manager.load(session_id).unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(loaded.current_step.as_deref(), Some("external-step"));
assert!(matches!(
loaded.phase,
SessionPhase::Failed(ref message) if message == "external edit"
));
assert!(
!repo.join("second.txt").exists(),
"aborting on conflict should prevent later steps from running"
);
let log = fs::read_to_string(&log_path).unwrap_or_else(|e| {
panic!("conflict resolution should be logged for abort tests: {e:?}")
});
assert!(
log.contains("abort"),
"expected abort decision in log, got: {log}"
);
}
#[tokio::test(flavor = "current_thread")]
async fn test_run_current_branch_conflict_noninteractive_returns_error_without_prompt() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let session_id = "20260310140002";
let (mut process, repo, manager) =
setup_current_branch_conflict_session(&tmp, session_id, "noninteractive conflict");
let log_path = tmp.path().join("conflict-noninteractive.log");
configure_conflict_test_env(&mut process, false, None, &log_path);
let run_fut = run(run_args(session_id));
let mutate_fut = mutate_state_after_first_step(
&manager,
session_id,
|_| repo.clone(),
write_external_failed_state,
);
let (result, ()) = tokio::join!(run_fut, mutate_fut);
match result {
Err(CruiseError::SessionStateConflict(message)) => {
assert!(
message.contains("state.json"),
"noninteractive conflict should mention state.json: {message}"
);
}
other => panic!("expected SessionStateConflict, got {other:?}"),
}
let loaded = manager.load(session_id).unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(loaded.current_step.as_deref(), Some("external-step"));
assert!(matches!(
loaded.phase,
SessionPhase::Failed(ref message) if message == "external edit"
));
assert!(
!repo.join("second.txt").exists(),
"noninteractive conflicts should stop before later steps run"
);
}
#[tokio::test(flavor = "current_thread")]
async fn test_run_current_branch_conflict_abort_preserves_invalid_state_file() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let session_id = "20260310140003";
let (mut process, repo, manager) =
setup_current_branch_conflict_session(&tmp, session_id, "invalid json conflict");
let log_path = tmp.path().join("conflict-invalid-json.log");
configure_conflict_test_env(&mut process, true, Some("abort"), &log_path);
let run_fut = run(run_args(session_id));
let mutate_fut = mutate_state_after_first_step(
&manager,
session_id,
|_| repo.clone(),
|manager, id| {
fs::write(manager.state_path(id), "{invalid json")
.unwrap_or_else(|e| panic!("{e:?}"));
},
);
let (result, ()) = tokio::join!(run_fut, mutate_fut);
match result {
Err(CruiseError::SessionStateConflictAborted(message)) => {
assert!(
message.contains("state.json"),
"abort message should mention state.json: {message}"
);
}
other => panic!("expected SessionStateConflictAborted, got {other:?}"),
}
assert_eq!(
fs::read_to_string(manager.state_path(session_id)).unwrap_or_else(|e| panic!("{e:?}")),
"{invalid json"
);
assert!(
!repo.join("second.txt").exists(),
"aborting on invalid external JSON should stop before later steps run"
);
}
#[tokio::test(flavor = "current_thread")]
async fn test_run_current_branch_conflict_noninteractive_preserves_missing_state_file() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let session_id = "20260310140004";
let (mut process, repo, manager) =
setup_current_branch_conflict_session(&tmp, session_id, "missing state conflict");
let log_path = tmp.path().join("conflict-missing.log");
configure_conflict_test_env(&mut process, false, None, &log_path);
let run_fut = run(run_args(session_id));
let mutate_fut = mutate_state_after_first_step(
&manager,
session_id,
|_| repo.clone(),
|manager, id| {
fs::remove_file(manager.state_path(id)).unwrap_or_else(|e| panic!("{e:?}"));
},
);
let (result, ()) = tokio::join!(run_fut, mutate_fut);
match result {
Err(CruiseError::SessionStateConflict(message)) => {
assert!(
message.contains("state.json"),
"missing-file conflict should mention state.json: {message}"
);
}
other => panic!("expected SessionStateConflict, got {other:?}"),
}
assert!(
!manager.state_path(session_id).exists(),
"noninteractive conflict should preserve the missing state file"
);
assert!(
!repo.join("second.txt").exists(),
"noninteractive missing-file conflicts should stop before later steps run"
);
}
#[tokio::test(flavor = "current_thread")]
async fn test_run_all_forces_worktree_even_for_current_branch_sessions() {
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(get_cruise_home().unwrap_or_else(|e| panic!("{e:?}")));
let session_id = "20260309125000";
let session = make_current_branch_session(session_id, &repo, "batch run", "main");
manager.create(&session).unwrap_or_else(|e| panic!("{e:?}"));
write_config(
&manager,
session_id,
&single_command_config("edit", "printf batch > run-all.txt"),
);
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/104");
process.prepend_path(&bin_dir);
let result = run(RunArgs {
session: None,
all: true,
max_retries: 10,
rate_limit_retries: 0,
dry_run: false,
})
.await;
assert!(result.is_ok(), "expected run --all to succeed: {result:?}");
let loaded = manager.load(session_id).unwrap_or_else(|e| panic!("{e:?}"));
assert!(
loaded.worktree_path.is_some(),
"run --all should still use a worktree execution path"
);
assert!(
loaded.worktree_branch.is_some(),
"run --all should assign a worktree branch"
);
assert_eq!(
loaded.pr_url.as_deref(),
Some("https://github.com/owner/repo/pull/104"),
"run --all should keep the existing PR flow"
);
assert!(
!repo.join("run-all.txt").exists(),
"run --all should not write changes into the base repository"
);
assert!(
manager
.worktrees_dir()
.join(session_id)
.join("run-all.txt")
.exists(),
"run --all should write changes inside the session worktree"
);
let gh_log_contents = fs::read_to_string(&gh_log).unwrap_or_default();
assert!(
gh_log_contents.contains("pr create --head"),
"run --all should still invoke PR creation through gh"
);
assert!(
gh_log_contents.contains("--draft"),
"gh pr create should include --draft flag, got: {gh_log_contents}"
);
}
#[tokio::test(flavor = "current_thread")]
async fn test_run_all_preserves_invalid_external_state_without_failing_summary_reload() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let mut process = ProcessStateGuard::new(tmp.path());
let repo = create_repo_with_origin(&tmp);
process.set_current_dir(&repo);
let manager = SessionManager::new(get_cruise_home().unwrap_or_else(|e| panic!("{e:?}")));
let session_id = "20260310140005";
let mut session = SessionState::new(
session_id.to_string(),
repo.clone(),
"cruise.yaml".to_string(),
"run all conflict".to_string(),
);
session.phase = SessionPhase::Planned;
manager.create(&session).unwrap_or_else(|e| panic!("{e:?}"));
write_config(&manager, session_id, &blocking_conflict_config());
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/105");
process.prepend_path(&bin_dir);
let log_path = tmp.path().join("run-all-conflict.log");
configure_conflict_test_env(&mut process, true, Some("abort"), &log_path);
let run_fut = run(RunArgs {
session: None,
all: true,
max_retries: 10,
rate_limit_retries: 0,
dry_run: false,
});
let mutate_fut = mutate_state_after_first_step(
&manager,
session_id,
|state| {
state.worktree_path.clone().unwrap_or_else(|| {
panic!("run --all should persist a worktree path before step execution")
})
},
|manager, id| {
fs::write(manager.state_path(id), "{invalid json")
.unwrap_or_else(|e| panic!("{e:?}"));
},
);
let (result, ()) = tokio::join!(run_fut, mutate_fut);
assert!(
result.is_ok(),
"run --all should not fail when summary reload sees preserved invalid state: {result:?}"
);
assert_eq!(
fs::read_to_string(manager.state_path(session_id)).unwrap_or_else(|e| panic!("{e:?}")),
"{invalid json"
);
assert!(
!gh_log.exists(),
"the aborted conflict session should not reach PR creation"
);
}
fn make_session(input: &str, phase: SessionPhase, pr_url: Option<&str>) -> SessionState {
let mut s = SessionState::new(
"20260101000000".to_string(),
std::path::PathBuf::from("/tmp"),
"test.yaml".to_string(),
input.to_string(),
);
s.phase = phase;
s.pr_url = pr_url.map(str::to_string);
s
}
#[test]
fn test_format_run_all_summary_suspended_session_shows_suspended_indicator() {
let results = vec![make_session("add feature", SessionPhase::Suspended, None)];
let summary = format_run_all_summary(&results);
let summary_plain = console::strip_ansi_codes(&summary).to_string();
assert!(
summary_plain.contains("add feature"),
"summary should contain input: {summary_plain}"
);
assert!(
summary_plain.contains("Suspended"),
"summary should contain Suspended indicator: {summary_plain}"
);
assert!(
!summary_plain.is_empty(),
"summary should not be empty: {summary_plain}"
);
}
#[test]
fn test_format_run_all_summary_mixed_with_suspended() {
let results = vec![
make_session(
"task a",
SessionPhase::Completed,
Some("https://github.com/org/repo/pull/1"),
),
make_session("task b", SessionPhase::Suspended, None),
make_session(
"task c",
SessionPhase::Failed("build error".to_string()),
None,
),
];
let summary = format_run_all_summary(&results);
let summary_plain = console::strip_ansi_codes(&summary).to_string();
assert!(
summary_plain.contains("task a"),
"summary should contain task a: {summary_plain}"
);
assert!(
summary_plain.contains("task b"),
"summary should contain task b: {summary_plain}"
);
assert!(
summary_plain.contains("task c"),
"summary should contain task c: {summary_plain}"
);
assert!(
summary_plain.contains("=== Run All Summary (3 sessions) ==="),
"header should show correct count: {summary_plain}"
);
assert!(
summary_plain.contains("Suspended"),
"summary should distinguish Suspended from Failed: {summary_plain}"
);
assert!(
summary_plain.contains("Failed"),
"summary should show Failed reason: {summary_plain}"
);
}
#[test]
fn test_format_run_all_summary_empty_returns_empty_string() {
let results: Vec<SessionState> = vec![];
let summary = format_run_all_summary(&results);
assert!(
summary.is_empty(),
"expected empty string, got: {summary:?}"
);
}
#[test]
fn test_format_run_all_summary_single_completed_with_pr() {
let results = vec![make_session(
"add login feature",
SessionPhase::Completed,
Some("https://github.com/org/repo/pull/42"),
)];
let summary = format_run_all_summary(&results);
assert!(
summary.contains("add login feature"),
"summary should contain input: {summary}"
);
assert!(
summary.contains("https://github.com/org/repo/pull/42"),
"summary should contain PR URL: {summary}"
);
assert!(
!summary.contains("Failed") && !summary.contains('x'),
"completed session should not show failure: {summary}"
);
}
#[test]
fn test_format_run_all_summary_single_completed_without_pr() {
let results = vec![make_session(
"refactor database layer",
SessionPhase::Completed,
None,
)];
let summary = format_run_all_summary(&results);
assert!(
summary.contains("refactor database layer"),
"summary should contain input: {summary}"
);
assert!(
!summary.contains("Failed") && !summary.contains('x'),
"completed session should not show failure: {summary}"
);
}
#[test]
fn test_format_run_all_summary_single_failed_session() {
let results = vec![make_session(
"fix login bug",
SessionPhase::Failed("CI timeout".to_string()),
None,
)];
let summary = format_run_all_summary(&results);
assert!(
summary.contains("fix login bug"),
"summary should contain input: {summary}"
);
assert!(
summary.contains("Failed: CI timeout"),
"summary should contain failure prefix and error message: {summary}"
);
}
#[test]
fn test_format_run_all_summary_mixed_results() {
let results = vec![
make_session(
"add auth module",
SessionPhase::Completed,
Some("https://github.com/org/repo/pull/10"),
),
make_session(
"fix broken test",
SessionPhase::Failed("build error".to_string()),
None,
),
];
let summary = format_run_all_summary(&results);
assert!(
summary.contains("add auth module"),
"summary should contain first input: {summary}"
);
assert!(
summary.contains("https://github.com/org/repo/pull/10"),
"summary should contain PR URL: {summary}"
);
assert!(
summary.contains("fix broken test"),
"summary should contain second input: {summary}"
);
assert!(
summary.contains("Failed: build error"),
"summary should contain failure prefix and error message for second session: {summary}"
);
}
#[test]
fn test_format_run_all_summary_mixed_with_completed_no_pr() {
let results = vec![
make_session(
"add auth module",
SessionPhase::Completed,
Some("https://github.com/org/repo/pull/10"),
),
make_session("refactor cache layer", SessionPhase::Completed, None),
make_session(
"fix broken test",
SessionPhase::Failed("CI timeout".to_string()),
None,
),
];
let summary = format_run_all_summary(&results);
assert!(
summary.contains("add auth module"),
"summary should contain first session: {summary}"
);
assert!(
summary.contains("https://github.com/org/repo/pull/10"),
"summary should show PR URL for success: {summary}"
);
assert!(
summary.contains("refactor cache layer"),
"summary should contain second session: {summary}"
);
let refactor_line = summary
.lines()
.find(|l| l.contains("refactor cache layer"))
.unwrap_or_else(|| panic!("refactor cache layer line not found in summary"));
assert!(
!refactor_line.contains("Failed") && !refactor_line.contains('x'),
"completed session should not show failure, got: {refactor_line:?}"
);
let failed_line = summary
.lines()
.find(|l| l.contains("fix broken test"))
.unwrap_or_else(|| panic!("fix broken test line not found in summary"));
assert!(
failed_line.contains("Failed: CI timeout"),
"failed session should show failure prefix and error message, got: {failed_line:?}"
);
}
#[test]
fn test_format_run_all_summary_long_input_is_truncated() {
let long_input = "a".repeat(200);
let results = vec![make_session(&long_input, SessionPhase::Completed, None)];
let summary = format_run_all_summary(&results);
for line in summary.lines() {
assert!(
line.chars().count() <= 300,
"line too long ({} chars): {line}",
line.chars().count()
);
}
}
#[test]
fn test_apply_run_result_completed_sets_completed_phase() {
let mut session = make_session("some task", SessionPhase::Running, None);
apply_run_result_to_session(&mut session, &Ok(()));
assert!(
matches!(session.phase, SessionPhase::Completed),
"Expected Completed, got {:?}",
session.phase
);
}
#[test]
fn test_apply_run_result_step_paused_keeps_running_phase() {
let mut session = make_session("some task", SessionPhase::Running, None);
apply_run_result_to_session(&mut session, &Err(CruiseError::StepPaused));
assert!(
matches!(session.phase, SessionPhase::Running),
"Expected Running after StepPaused, got {:?}",
session.phase
);
}
#[test]
fn test_apply_run_result_other_error_sets_failed_phase() {
let mut session = make_session("some task", SessionPhase::Running, None);
apply_run_result_to_session(
&mut session,
&Err(CruiseError::CommandError("some failure".to_string())),
);
assert!(
matches!(session.phase, SessionPhase::Failed(_)),
"Expected Failed, got {:?}",
session.phase
);
}
#[test]
fn test_apply_run_result_step_paused_does_not_set_completed_at() {
let mut session = make_session("some task", SessionPhase::Running, None);
apply_run_result_to_session(&mut session, &Err(CruiseError::StepPaused));
assert!(
session.completed_at.is_none(),
"completed_at should not be set on pause"
);
}
#[test]
fn test_apply_run_result_completed_sets_completed_at() {
let mut session = make_session("some task", SessionPhase::Running, None);
apply_run_result_to_session(&mut session, &Ok(()));
assert!(
session.completed_at.is_some(),
"completed_at should be set on completion"
);
}
#[test]
fn test_apply_run_result_failed_sets_completed_at() {
let mut session = make_session("some task", SessionPhase::Running, None);
apply_run_result_to_session(&mut session, &Err(CruiseError::Other("fatal".to_string())));
assert!(
session.completed_at.is_some(),
"completed_at should be set on failure"
);
}
#[test]
fn test_prompt_workspace_mode_returns_worktree_when_noninteractive() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let mut process = ProcessStateGuard::new(tmp.path());
process.set_env(TEST_STDIN_IS_TERMINAL_ENV, "0");
process.remove_env(TEST_WORKSPACE_MODE_ENV);
let result = prompt_workspace_mode();
assert_eq!(
result.unwrap_or_else(|e| panic!("{e:?}")),
WorkspaceMode::Worktree,
"non-interactive should default to Worktree"
);
}
#[test]
fn test_prompt_workspace_mode_returns_current_branch_via_test_env() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let mut process = ProcessStateGuard::new(tmp.path());
process.set_env(TEST_WORKSPACE_MODE_ENV, "current_branch");
let result = prompt_workspace_mode();
assert_eq!(
result.unwrap_or_else(|e| panic!("{e:?}")),
WorkspaceMode::CurrentBranch,
"env override should select CurrentBranch"
);
}
#[test]
fn test_prompt_workspace_mode_returns_worktree_via_test_env() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let mut process = ProcessStateGuard::new(tmp.path());
process.set_env(TEST_WORKSPACE_MODE_ENV, "worktree");
let result = prompt_workspace_mode();
assert_eq!(
result.unwrap_or_else(|e| panic!("{e:?}")),
WorkspaceMode::Worktree,
"env override should select Worktree"
);
}
#[tokio::test(flavor = "current_thread")]
async fn test_run_single_prompts_on_fresh_default_session_and_selects_current_branch() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let mut process = ProcessStateGuard::new(tmp.path());
let repo = create_repo_with_origin(&tmp);
process.set_current_dir(&repo);
process.set_env(TEST_STDIN_IS_TERMINAL_ENV, "1");
process.set_env(TEST_WORKSPACE_MODE_ENV, "current_branch");
let manager = SessionManager::new(get_cruise_home().unwrap_or_else(|e| panic!("{e:?}")));
let session_id = "20260321091000";
let mut session = SessionState::new(
session_id.to_string(),
repo.clone(),
"cruise.yaml".to_string(),
"run in place".to_string(),
);
session.phase = SessionPhase::Planned;
manager.create(&session).unwrap_or_else(|e| panic!("{e:?}"));
write_config(
&manager,
session_id,
&single_command_config("edit", "printf in-place > in-place.txt"),
);
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/300");
process.prepend_path(&bin_dir);
let result = run(run_args(session_id)).await;
assert!(
result.is_ok(),
"expected run to succeed when prompt selects current_branch: {result:?}"
);
assert!(
repo.join("in-place.txt").exists(),
"current_branch selection should write changes into the base repository"
);
assert!(
!manager.worktrees_dir().join(session_id).exists(),
"current_branch selection should not create a worktree directory"
);
assert!(
!gh_log.exists(),
"current_branch selection should not invoke gh"
);
}
#[tokio::test(flavor = "current_thread")]
async fn test_run_single_saves_workspace_mode_and_target_branch_after_prompt_selects_current_branch()
{
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let mut process = ProcessStateGuard::new(tmp.path());
let repo = create_repo_with_origin(&tmp);
process.set_current_dir(&repo);
process.set_env(TEST_STDIN_IS_TERMINAL_ENV, "1");
process.set_env(TEST_WORKSPACE_MODE_ENV, "current_branch");
let manager = SessionManager::new(get_cruise_home().unwrap_or_else(|e| panic!("{e:?}")));
let session_id = "20260321091001";
let mut session = SessionState::new(
session_id.to_string(),
repo.clone(),
"cruise.yaml".to_string(),
"save mode test".to_string(),
);
session.phase = SessionPhase::Planned;
manager.create(&session).unwrap_or_else(|e| panic!("{e:?}"));
write_config(
&manager,
session_id,
&single_command_config("edit", "printf mode > mode.txt"),
);
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/301");
process.prepend_path(&bin_dir);
run(run_args(session_id))
.await
.unwrap_or_else(|e| panic!("{e:?}"));
let loaded = manager.load(session_id).unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(
loaded.workspace_mode,
WorkspaceMode::CurrentBranch,
"workspace_mode should be persisted as CurrentBranch after prompt selection"
);
assert_eq!(
loaded.target_branch.as_deref(),
Some("main"),
"target_branch should be set to the current branch at run time"
);
}
#[tokio::test(flavor = "current_thread")]
async fn test_run_single_does_not_prompt_when_session_already_has_current_branch_mode() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let mut process = ProcessStateGuard::new(tmp.path());
let repo = create_repo_with_origin(&tmp);
process.set_current_dir(&repo);
process.set_env(TEST_STDIN_IS_TERMINAL_ENV, "1");
process.remove_env(TEST_WORKSPACE_MODE_ENV);
let manager = SessionManager::new(get_cruise_home().unwrap_or_else(|e| panic!("{e:?}")));
let session_id = "20260321091002";
let session =
make_current_branch_session(session_id, &repo, "already current branch", "main");
manager.create(&session).unwrap_or_else(|e| panic!("{e:?}"));
write_config(
&manager,
session_id,
&single_command_config("edit", "printf skip > skip-prompt.txt"),
);
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/302");
process.prepend_path(&bin_dir);
let result = run(run_args(session_id)).await;
assert!(
result.is_ok(),
"expected run to succeed without prompting: {result:?}"
);
assert!(
repo.join("skip-prompt.txt").exists(),
"already-CurrentBranch session should write changes into the base repository"
);
}
#[tokio::test(flavor = "current_thread")]
async fn test_run_single_does_not_prompt_when_resuming_saved_current_branch_session() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let mut process = ProcessStateGuard::new(tmp.path());
let repo = create_repo_with_origin(&tmp);
process.set_current_dir(&repo);
process.set_env(TEST_STDIN_IS_TERMINAL_ENV, "1");
process.remove_env(TEST_WORKSPACE_MODE_ENV);
let manager = SessionManager::new(get_cruise_home().unwrap_or_else(|e| panic!("{e:?}")));
let session_id = "20260321091003";
let mut session =
make_current_branch_session(session_id, &repo, "resume no prompt", "main");
session.phase = SessionPhase::Running;
session.current_step = Some("second".to_string());
manager.create(&session).unwrap_or_else(|e| panic!("{e:?}"));
write_config(
&manager,
session_id,
r"command:
- cat
steps:
first:
command: |
printf first > first.txt
second:
command: |
printf second > second.txt
",
);
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/303");
process.prepend_path(&bin_dir);
let result = run(run_args(session_id)).await;
assert!(
result.is_ok(),
"expected resume to succeed without prompting: {result:?}"
);
assert!(
!repo.join("first.txt").exists(),
"resume should skip already-executed earlier steps"
);
assert!(
repo.join("second.txt").exists(),
"resume should execute from the saved current step"
);
}
#[tokio::test(flavor = "current_thread")]
async fn test_run_single_defaults_to_worktree_when_stdin_is_not_terminal() {
let tmp = TempDir::new().unwrap_or_else(|e| panic!("{e:?}"));
let mut process = ProcessStateGuard::new(tmp.path());
let repo = create_repo_with_origin(&tmp);
process.set_current_dir(&repo);
process.set_env(TEST_STDIN_IS_TERMINAL_ENV, "0");
process.remove_env(TEST_WORKSPACE_MODE_ENV);
let manager = SessionManager::new(get_cruise_home().unwrap_or_else(|e| panic!("{e:?}")));
let session_id = "20260321091004";
let mut session = SessionState::new(
session_id.to_string(),
repo.clone(),
"cruise.yaml".to_string(),
"default to worktree".to_string(),
);
session.phase = SessionPhase::Planned;
manager.create(&session).unwrap_or_else(|e| panic!("{e:?}"));
write_config(
&manager,
session_id,
&single_command_config("edit", "printf wt > wt.txt"),
);
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/304");
process.prepend_path(&bin_dir);
let result = run(run_args(session_id)).await;
assert!(
result.is_ok(),
"expected run to succeed in worktree mode: {result:?}"
);
let loaded = manager.load(session_id).unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(
loaded.workspace_mode,
WorkspaceMode::Worktree,
"non-interactive stdin should default to Worktree mode"
);
assert!(
!repo.join("wt.txt").exists(),
"worktree mode should not write changes into the base repository"
);
assert!(
manager
.worktrees_dir()
.join(session_id)
.join("wt.txt")
.exists(),
"worktree mode should write changes into the session worktree"
);
}
#[tokio::test(flavor = "current_thread")]
async fn test_run_all_picks_up_session_added_while_first_session_is_running() {
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(get_cruise_home().unwrap_or_else(|e| panic!("{e:?}")));
let session_id_1 = "20260403400000";
let session_id_2 = "20260403400001";
let mut session_1 = SessionState::new(
session_id_1.to_string(),
repo.clone(),
"cruise.yaml".to_string(),
"first task".to_string(),
);
session_1.phase = SessionPhase::Planned;
manager
.create(&session_1)
.unwrap_or_else(|e| panic!("{e:?}"));
write_config(&manager, session_id_1, &blocking_conflict_config());
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/201");
process.prepend_path(&bin_dir);
let run_fut = run(RunArgs {
session: None,
all: true,
max_retries: 10,
rate_limit_retries: 0,
dry_run: false,
});
let add_and_unblock_fut = async {
wait_for_session_step(&manager, session_id_1, "first").await;
let mut session_2 = SessionState::new(
session_id_2.to_string(),
repo.clone(),
"cruise.yaml".to_string(),
"second task added mid-run".to_string(),
);
session_2.phase = SessionPhase::Planned;
manager
.create(&session_2)
.unwrap_or_else(|e| panic!("{e:?}"));
write_config(
&manager,
session_id_2,
&single_command_config("do", "printf done2 > session2-output.txt"),
);
let state_1 = manager
.load(session_id_1)
.unwrap_or_else(|e| panic!("{e:?}"));
let worktree = state_1
.worktree_path
.clone()
.unwrap_or_else(|| panic!("session_1 should have worktree_path set when Running"));
fs::write(worktree.join("proceed.txt"), "go").unwrap_or_else(|e| panic!("{e:?}"));
};
let (result, ()) = tokio::join!(run_fut, add_and_unblock_fut);
assert!(
result.is_ok(),
"run --all should succeed even when a session is added mid-run: {result:?}"
);
let state_2 = manager
.load(session_id_2)
.unwrap_or_else(|e| panic!("{e:?}"));
assert!(
matches!(state_2.phase, SessionPhase::Completed),
"session_2 added mid-run should be Completed, got {:?}",
state_2.phase
);
assert!(
manager
.worktrees_dir()
.join(session_id_2)
.join("session2-output.txt")
.exists(),
"session_2 command should have written session2-output.txt in its worktree"
);
}
}