use devflow_core::config::{DEVELOP, FEATURE_PREFIX};
use devflow_core::git::GitFlow;
use devflow_core::mode::Mode;
use devflow_core::state::AgentKind;
use devflow_core::worktree;
use std::path::{Path, PathBuf};
use crate::CliError;
use crate::commands::start;
use devflow_core::phase_id::PhaseId;
pub(crate) fn ensure_phase_worktree(
project_root: &Path,
phase: PhaseId,
force: bool,
) -> Result<PathBuf, CliError> {
let wt = worktree::phase_path(project_root, phase);
let branch = format!("{FEATURE_PREFIX}phase-{padded}", padded = phase.padded());
if force {
if wt.exists() {
worktree::remove(project_root, &wt, true)?;
}
let _ = GitFlow::new(project_root).delete_branch(&branch, true);
}
match worktree::add(project_root, &wt, &branch, DEVELOP, true) {
Ok(()) => Ok(wt),
Err(devflow_core::worktree::WorktreeError::Exists(path)) => {
Err(CliError::Message(format!(
"worktree already exists at {} — use --force to recreate it",
path.display()
)))
}
Err(err) => Err(err.into()),
}
}
fn parse_phase_agent_pairs(
phases: &str,
agents: Option<&str>,
) -> Result<Vec<(PhaseId, AgentKind)>, CliError> {
let phases: Vec<PhaseId> = phases
.split(',')
.map(|p| p.trim())
.filter(|p| !p.is_empty())
.map(|p| {
p.parse::<PhaseId>()
.map_err(|err| CliError::Message(err.to_string()))
})
.collect::<Result<_, _>>()?;
if phases.is_empty() {
return Err(CliError::Message("no phases given".into()));
}
let agents: Vec<AgentKind> = match agents {
Some(list) => list
.split(',')
.map(|a| a.trim())
.filter(|a| !a.is_empty())
.map(|a| {
a.parse::<AgentKind>()
.map_err(|err| CliError::Message(err.to_string()))
})
.collect::<Result<_, _>>()?,
None => Vec::new(),
};
if agents.len() > phases.len() {
return Err(CliError::Message(format!(
"got {} agents for {} phases — provide at most one agent per phase",
agents.len(),
phases.len()
)));
}
Ok(phases
.into_iter()
.enumerate()
.map(|(i, phase)| (phase, agents.get(i).copied().unwrap_or(AgentKind::Claude)))
.collect())
}
pub(crate) fn parallel(
project_root: &Path,
phases: &str,
agents: Option<&str>,
mode: Mode,
force: bool,
) -> Result<(), CliError> {
let pairs = parse_phase_agent_pairs(phases, agents)?;
println!("launching {} phase(s) in parallel worktrees", pairs.len());
for (phase, agent) in pairs {
println!("\n=== phase {phase} ({agent}) ===");
start(
project_root,
phase,
agent,
mode,
force,
true,
false,
None,
false,
false,
)?;
}
Ok(())
}
pub(crate) fn retry_after_from_reason(reason: Option<&str>) -> String {
reason
.and_then(|s| s.strip_prefix("rate limited until "))
.unwrap_or("unknown")
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pairs_default_missing_agents_to_claude() {
let pairs = parse_phase_agent_pairs("7,8", Some("codex")).unwrap();
assert_eq!(
pairs,
vec![
(PhaseId::new(7), AgentKind::Codex),
(PhaseId::new(8), AgentKind::Claude)
]
);
}
#[test]
fn pairs_match_agents_positionally() {
let pairs = parse_phase_agent_pairs("7, 8", Some("claude, codex")).unwrap();
assert_eq!(
pairs,
vec![
(PhaseId::new(7), AgentKind::Claude),
(PhaseId::new(8), AgentKind::Codex)
]
);
}
#[test]
fn pairs_default_all_to_claude_without_agents() {
let pairs = parse_phase_agent_pairs("3,4", None).unwrap();
assert_eq!(
pairs,
vec![
(PhaseId::new(3), AgentKind::Claude),
(PhaseId::new(4), AgentKind::Claude)
]
);
}
#[test]
fn pairs_reject_more_agents_than_phases() {
let err = parse_phase_agent_pairs("7", Some("claude,codex")).unwrap_err();
assert!(matches!(err, CliError::Message(_)));
}
#[test]
fn pairs_reject_invalid_phase() {
assert!(parse_phase_agent_pairs("7,x", None).is_err());
assert!(parse_phase_agent_pairs("", None).is_err());
}
#[test]
fn retry_after_from_reason_strips_prefix() {
assert_eq!(
retry_after_from_reason(Some("rate limited until 2026-06-18T15:45:30Z")),
"2026-06-18T15:45:30Z"
);
assert_eq!(retry_after_from_reason(Some("usage limit")), "unknown");
assert_eq!(retry_after_from_reason(None), "unknown");
}
}