use crate::config::MuxMode;
use crate::multiplexer::handle::mode_label;
use crate::multiplexer::{MuxHandle, create_backend, detect_backend, util::prefixed};
use crate::prompt::{Prompt, PromptDocument, foreach_from_frontmatter};
use crate::spinner;
use crate::template::{
TemplateEnv, WorktreeSpec, create_template_env, generate_worktree_specs, parse_foreach_matrix,
render_prompt_body, validate_template_variables,
};
use crate::workflow::SetupOptions;
use crate::workflow::pr::detect_remote_branch;
use crate::workflow::prompt_loader::{PromptLoadArgs, load_prompt, parse_prompt_with_frontmatter};
use crate::{config, git, workflow};
use anyhow::{Context, Result, anyhow, bail};
use serde_json::Value;
use std::collections::BTreeMap;
use std::io::{IsTerminal, Read};
pub use super::args::{MultiArgs, PromptArgs, RescueArgs, SetupFlags};
const STDIN_INPUT_VAR: &str = "input";
const STDIN_MAX_BYTES: u64 = 10 * 1024 * 1024;
fn generate_branch_name_with_spinner(
prompt_text: Option<&str>,
config: &config::Config,
) -> Result<String> {
let prompt_text = prompt_text.ok_or_else(|| anyhow!("Prompt is required for --auto-name"))?;
let model = config.auto_name.as_ref().and_then(|c| c.model.as_deref());
let system_prompt = config
.auto_name
.as_ref()
.and_then(|c| c.system_prompt.as_deref());
let config_command = config
.auto_name
.as_ref()
.and_then(|c| c.command.as_deref())
.map(str::trim)
.filter(|s| !s.is_empty());
let profile_command =
crate::multiplexer::agent::resolve_profile(config.agent.as_deref()).auto_name_command();
let effective_command = config_command.or(profile_command);
tracing::info!(
config_command = config_command,
profile_command = profile_command,
effective_command = effective_command,
agent = config.agent.as_deref().unwrap_or("none"),
"resolved auto-name command"
);
let program_name = effective_command
.and_then(|cmd| cmd.split_whitespace().next())
.unwrap_or("llm");
let spinner_msg = format!("Generating branch name with {}", program_name);
let generated = spinner::with_spinner(&spinner_msg, || {
crate::llm::generate_branch_name(prompt_text, model, system_prompt, effective_command)
})?;
println!(" Branch: {}", generated);
Ok(generated)
}
fn read_stdin_lines() -> Result<Vec<String>> {
if std::io::stdin().is_terminal() {
return Ok(Vec::new());
}
let mut buffer = String::new();
std::io::stdin()
.take(STDIN_MAX_BYTES)
.read_to_string(&mut buffer)
.context("Failed to read from stdin")?;
let lines: Vec<String> = buffer
.lines()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
Ok(lines)
}
fn check_preconditions() -> Result<()> {
let is_git = git::is_git_repo()?;
let mux = create_backend(detect_backend());
let is_mux_running = mux.is_running()?;
if is_git && is_mux_running {
return Ok(());
}
let mut errors = Vec::new();
if !is_mux_running {
errors.push(format!("{} is not running.", mux.name()));
}
if !is_git {
errors.push("Current directory is not a git repository.".to_string());
}
errors.push("".to_string());
if !is_mux_running {
errors.push(format!("Please start a {} session first.", mux.name()));
}
if !is_git {
errors.push("Please run this command from within a git repository.".to_string());
}
Err(anyhow!(errors.join("\n")))
}
fn resolve_layout(config: &mut config::Config, layout_name: &str) -> Result<()> {
let layouts = config.layouts.as_ref().ok_or_else(|| {
anyhow!(
"Layout '{}' requested but no layouts are defined in config",
layout_name
)
})?;
let layout = layouts.get(layout_name).ok_or_else(|| {
let mut available: Vec<_> = layouts.keys().collect();
available.sort();
anyhow!(
"Layout '{}' not found. Available layouts: {}",
layout_name,
available
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ")
)
})?;
config.panes = Some(layout.panes.clone());
config.windows = None; Ok(())
}
#[allow(clippy::too_many_arguments)]
pub fn run(
branch_name: Option<&str>,
pr: Option<u32>,
auto_name: bool,
base: Option<&str>,
name: Option<String>,
prompt_args: PromptArgs,
setup: SetupFlags,
rescue: RescueArgs,
multi: MultiArgs,
layout: Option<String>,
fork: Option<String>,
wait: bool,
session: bool,
) -> Result<()> {
if crate::sandbox::guest::is_sandbox_guest() {
if layout.is_some() {
bail!("--layout is not supported from inside a sandbox");
}
if fork.is_some() {
bail!("--fork is not supported from inside a sandbox");
}
return run_add_via_rpc(
branch_name,
auto_name,
&prompt_args,
&setup,
&rescue,
&multi,
base,
pr,
name.as_deref(),
wait,
session,
);
}
check_preconditions()?;
let sandbox_override = setup.sandbox;
let mut initial_config = config::Config::load(multi.agent.first().map(|s| s.as_str()))?;
let fork_source = if let Some(ref fork_arg) = fork {
let agent_name = initial_config.agent.as_deref().unwrap_or("claude");
let forker =
crate::multiplexer::conversation::resolve_forker(agent_name).ok_or_else(|| {
anyhow!(
"Agent '{}' does not support conversation forking",
agent_name
)
})?;
let source_path = git::get_repo_root()?;
let session = if fork_arg.is_empty() {
forker
.find_latest_conversation(&source_path)?
.ok_or_else(|| {
anyhow!(
"No conversations found in current worktree.\n\
Path searched: {}",
source_path.display()
)
})?
} else {
forker
.find_conversation(&source_path, fork_arg)?
.ok_or_else(|| {
anyhow!(
"No conversation matching '{}' found in current worktree",
fork_arg
)
})?
};
Some(crate::workflow::types::ForkSource { forker, session })
} else {
None
};
let mode = if session {
MuxMode::Session
} else {
initial_config.mode()
};
if let Some(layout_name) = &layout {
resolve_layout(&mut initial_config, layout_name)?;
}
let mut options = SetupOptions::new(!setup.no_hooks, !setup.no_file_ops, !setup.no_pane_cmds);
options.focus_window = !setup.background;
options.open_if_exists = setup.open_if_exists;
options.mode = mode;
if auto_name && options.focus_window {
let config = config::Config::load(multi.agent.first().map(|s| s.as_str()))?;
if config
.auto_name
.as_ref()
.and_then(|c| c.background)
.unwrap_or(false)
{
options.focus_window = false;
}
}
let stdin_lines = read_stdin_lines()?;
let has_stdin = !stdin_lines.is_empty();
let is_explicit_multi =
has_stdin || multi.foreach.is_some() || multi.count.is_some() || multi.agent.len() > 1;
let (final_branch_name, preloaded_prompt, remote_branch_for_pr, deferred_auto_name) =
if auto_name {
let use_editor = prompt_args.prompt.is_none() && prompt_args.prompt_file.is_none();
if has_stdin && (prompt_args.prompt_editor || use_editor) {
return Err(anyhow!(
"Cannot use interactive prompt editor when piping input from stdin.\n\
Please provide a prompt via --prompt or --prompt-file."
));
}
let prompt = load_prompt(&PromptLoadArgs {
prompt_editor: use_editor || prompt_args.prompt_editor,
prompt_inline: prompt_args.prompt.as_deref(),
prompt_file: prompt_args.prompt_file.as_ref(),
})?
.ok_or_else(|| anyhow!("Prompt is required for --auto-name"))?;
let prompt_doc_preview = parse_prompt_with_frontmatter(&prompt, true)?;
let has_frontmatter_foreach = prompt_doc_preview.meta.foreach.is_some();
if is_explicit_multi || has_frontmatter_foreach {
("deferred".to_string(), Some(prompt), None, true)
} else {
let prompt_text = prompt.read_content()?;
let config = config::Config::load(multi.agent.first().map(|s| s.as_str()))?;
let generated = generate_branch_name_with_spinner(Some(&prompt_text), &config)?;
(generated, Some(prompt), None, false)
}
} else if let Some(pr_number) = pr {
let result = workflow::pr::resolve_pr_ref(pr_number, branch_name)?;
(result.local_branch, None, Some(result.remote_branch), false)
} else {
(
branch_name
.expect("branch_name required when --pr and --auto-name not provided")
.to_string(),
None,
None,
false,
)
};
let branch_name = &final_branch_name;
let cli_base = if remote_branch_for_pr.is_some() {
None
} else {
base
};
let config_base = initial_config.base_branch.as_deref();
if rescue.with_changes && multi.agent.len() > 1 {
return Err(anyhow!(
"--with-changes cannot be used with multiple --agent flags. Use zero or one --agent."
));
}
let has_multi_worktree = multi.agent.len() > 1
|| multi.count.is_some_and(|c| c > 1)
|| multi.foreach.is_some()
|| has_stdin;
if name.is_some() && has_multi_worktree {
return Err(anyhow!(
"--name cannot be used with multi-worktree generation (multiple --agent, --count, --foreach, or stdin).\n\
Use the default naming or set worktree_naming/worktree_prefix in config instead."
));
}
if rescue.with_changes {
let (mut rescue_config, rescue_location) =
config::Config::load_with_location(multi.agent.first().map(|s| s.as_str()))?;
if sandbox_override {
rescue_config.sandbox.enabled = Some(true);
}
if let Some(layout_name) = &layout {
resolve_layout(&mut rescue_config, layout_name)?;
}
let mux = create_backend(detect_backend());
let rescue_context = workflow::WorkflowContext::new(rescue_config, mux, rescue_location)?;
let handle =
crate::naming::derive_handle(branch_name, name.as_deref(), &rescue_context.config)?;
if handle_rescue_flow(
branch_name,
&handle,
&rescue,
&rescue_context,
options.clone(),
wait,
)? {
return Ok(());
}
}
let prompt_template = if let Some(p) = preloaded_prompt {
Some(p)
} else {
load_prompt(&PromptLoadArgs {
prompt_editor: prompt_args.prompt_editor,
prompt_inline: prompt_args.prompt.as_deref(),
prompt_file: prompt_args.prompt_file.as_ref(),
})?
};
let prompt_doc = if let Some(ref prompt_src) = prompt_template {
let implicit_editor =
auto_name && prompt_args.prompt.is_none() && prompt_args.prompt_file.is_none();
let from_editor_or_file = prompt_args.prompt_editor
|| implicit_editor
|| matches!(prompt_src, Prompt::FromFile(_));
Some(parse_prompt_with_frontmatter(
prompt_src,
from_editor_or_file,
)?)
} else {
None
};
if multi.count.is_some() && multi.agent.len() > 1 {
return Err(anyhow!(
"--count can only be used with zero or one --agent, but {} were provided",
multi.agent.len()
));
}
let has_foreach_in_prompt = prompt_doc
.as_ref()
.and_then(|d| d.meta.foreach.as_ref())
.is_some();
if has_foreach_in_prompt && !multi.agent.is_empty() {
return Err(anyhow!(
"Cannot use --agent when 'foreach' is defined in the prompt frontmatter. \
These multi-worktree generation methods are mutually exclusive."
));
}
let env = create_template_env();
let (remote_branch, template_base_name) = if let Some(ref pr_remote) = remote_branch_for_pr {
(Some(pr_remote.clone()), branch_name.to_string())
} else {
detect_remote_branch(branch_name, cli_base)?
};
let resolved_base = if remote_branch.is_some() {
None
} else {
cli_base.or(config_base)
};
let effective_foreach_rows =
determine_foreach_matrix(&multi, prompt_doc.as_ref(), stdin_lines)?;
let specs = generate_worktree_specs(
&template_base_name,
&multi.agent,
multi.count,
effective_foreach_rows.as_deref(),
&env,
&multi.branch_template,
)?;
if specs.is_empty() {
return Err(anyhow!("No worktree specifications were generated"));
}
if let Some(doc) = &prompt_doc
&& let Some(first_spec) = specs.first()
{
validate_template_variables(&env, &doc.body, &first_spec.template_context)
.context("Prompt template uses undefined variables")?;
}
let prompt_file_only =
prompt_args.prompt_file_only || initial_config.prompt_file_only.unwrap_or(false);
let mut plan = CreationPlan {
specs: &specs,
resolved_base,
remote_branch: remote_branch.as_deref(),
prompt_doc: prompt_doc.as_ref(),
options,
env: &env,
explicit_name: name.as_deref(),
wait,
deferred_auto_name,
max_concurrent: multi.max_concurrent,
sandbox_override,
prompt_file_only,
layout: layout.as_deref(),
fork_source,
};
plan.execute()
}
fn handle_rescue_flow(
branch_name: &str,
handle: &str,
rescue: &RescueArgs,
context: &workflow::WorkflowContext,
options: SetupOptions,
wait: bool,
) -> Result<bool> {
if !rescue.with_changes {
return Ok(false);
}
let mode = options.mode;
let result = workflow::create_with_changes(
branch_name,
handle,
rescue.include_untracked,
rescue.patch,
context,
options,
)
.context("Failed to move uncommitted changes")?;
println!(
"✓ Moved uncommitted changes to new worktree for branch '{}'\n Worktree: {}\n Original worktree is now clean",
result.branch_name,
result.worktree_path.display()
);
if wait {
MuxHandle::new(context.mux.as_ref(), mode, &context.prefix, handle).wait_until_closed()?;
}
Ok(true)
}
fn determine_foreach_matrix(
multi: &MultiArgs,
prompt_doc: Option<&PromptDocument>,
stdin_lines: Vec<String>,
) -> Result<Option<Vec<BTreeMap<String, String>>>> {
let has_stdin = !stdin_lines.is_empty();
let has_frontmatter_foreach = prompt_doc.and_then(|d| d.meta.foreach.as_ref()).is_some();
if has_stdin && multi.foreach.is_some() {
return Err(anyhow!("Cannot use --foreach when piping input from stdin"));
}
if has_stdin {
if has_frontmatter_foreach {
eprintln!("Warning: stdin input overrides prompt frontmatter 'foreach'");
}
let rows = stdin_lines
.into_iter()
.map(|line| {
let mut map = BTreeMap::new();
map.insert(STDIN_INPUT_VAR.to_string(), line.clone());
if line.starts_with('{')
&& let Ok(Value::Object(obj)) = serde_json::from_str(&line)
{
for (k, v) in obj {
let val_str = match v {
Value::String(s) => s,
Value::Null => String::new(),
other => other.to_string(),
};
map.insert(k, val_str);
}
}
map
})
.collect();
return Ok(Some(rows));
}
match (
&multi.foreach,
prompt_doc.and_then(|d| d.meta.foreach.as_ref()),
) {
(Some(cli_str), Some(_frontmatter_map)) => {
eprintln!("Warning: --foreach overrides prompt frontmatter");
Ok(Some(parse_foreach_matrix(cli_str)?))
}
(Some(cli_str), None) => Ok(Some(parse_foreach_matrix(cli_str)?)),
(None, Some(frontmatter_map)) => Ok(Some(foreach_from_frontmatter(frontmatter_map)?)),
(None, None) => Ok(None),
}
}
const WORKER_POOL_POLL_MS: u64 = 250;
struct CreationPlan<'a> {
specs: &'a [WorktreeSpec],
resolved_base: Option<&'a str>,
remote_branch: Option<&'a str>,
prompt_doc: Option<&'a PromptDocument>,
options: SetupOptions,
env: &'a TemplateEnv,
explicit_name: Option<&'a str>,
wait: bool,
deferred_auto_name: bool,
max_concurrent: Option<u32>,
sandbox_override: bool,
prompt_file_only: bool,
layout: Option<&'a str>,
fork_source: Option<crate::workflow::types::ForkSource>,
}
impl<'a> CreationPlan<'a> {
fn execute(&mut self) -> Result<()> {
self.create_worktrees()
}
fn create_worktrees(&mut self) -> Result<()> {
if self.specs.len() > 1 {
println!("Preparing to create {} worktrees...", self.specs.len());
}
let mux = create_backend(detect_backend());
let mut created_targets = Vec::new();
let mut active_targets: Vec<String> = Vec::new();
let mode = self.options.mode;
for (i, spec) in self.specs.iter().enumerate() {
if let Some(limit) = self.max_concurrent {
let limit = limit as usize;
if active_targets.len() >= limit {
loop {
if mode == MuxMode::Session {
let live_sessions = mux.get_all_session_names()?;
active_targets.retain(|t| live_sessions.contains(t));
} else {
active_targets = mux.filter_active_windows(&active_targets)?;
}
if active_targets.len() < limit {
break;
}
std::thread::sleep(std::time::Duration::from_millis(WORKER_POOL_POLL_MS));
}
}
}
let (mut config, config_location) =
config::Config::load_with_location(spec.agent.as_deref())?;
if self.sandbox_override {
config.sandbox.enabled = Some(true);
}
if let Some(layout_name) = self.layout {
resolve_layout(&mut config, layout_name)?;
}
let rendered_prompt = if let Some(doc) = self.prompt_doc {
Some(
render_prompt_body(&doc.body, self.env, &spec.template_context)
.with_context(|| format!("Failed to render prompt for spec index {}", i))?,
)
} else {
None
};
let final_branch_name = if self.deferred_auto_name {
generate_branch_name_with_spinner(rendered_prompt.as_deref(), &config)?
} else {
spec.branch_name.clone()
};
if self.specs.len() > 1 {
println!(
"\n--- [{}/{}] Creating worktree: {} ---",
i + 1,
self.specs.len(),
final_branch_name
);
}
let handle =
crate::naming::derive_handle(&final_branch_name, self.explicit_name, &config)?;
let prompt_for_spec = rendered_prompt.map(Prompt::Inline);
super::announce_hooks(&config, Some(&self.options), super::HookPhase::PostCreate);
let fork_for_spec = if i == self.specs.len() - 1 {
self.fork_source.take()
} else if let Some(ref fork) = self.fork_source {
let agent_name = spec
.agent
.as_deref()
.or(config.agent.as_deref())
.unwrap_or("claude");
crate::multiplexer::conversation::resolve_forker(agent_name).map(|forker| {
crate::workflow::types::ForkSource {
forker,
session: fork.session.clone(),
}
})
} else {
None
};
let context = workflow::WorkflowContext::new(config, mux.clone(), config_location)?;
let result = workflow::create(
&context,
workflow::CreateArgs {
branch_name: &final_branch_name,
handle: &handle,
base_branch: self.resolved_base,
remote_branch: self.remote_branch,
prompt: prompt_for_spec.as_ref(),
options: self.options.clone(),
agent: spec.agent.as_deref(),
is_explicit_name: self.explicit_name.is_some(),
prompt_file_only: self.prompt_file_only,
fork_source: fork_for_spec,
},
)
.with_context(|| {
format!(
"Failed to create worktree environment for branch '{}'",
final_branch_name
)
})?;
let full_window_name = prefixed(&context.prefix, &result.resolved_handle);
if self.wait {
created_targets.push(full_window_name.clone());
}
if self.max_concurrent.is_some() {
active_targets.push(full_window_name);
}
if result.post_create_hooks_run > 0 {
println!("✓ Setup complete");
}
println!(
"✓ Successfully created worktree and tmux {} for '{}'",
mode_label(mode),
result.branch_name
);
if let Some(ref base) = result.base_branch {
println!(" Base: {}", base);
}
println!(" Worktree: {}", result.worktree_path.display());
}
if self.wait && !created_targets.is_empty() {
if mode == MuxMode::Session {
for session_name in &created_targets {
mux.wait_until_session_closed(session_name)?;
}
} else {
mux.wait_until_windows_closed(&created_targets)?;
}
}
Ok(())
}
}
#[allow(clippy::too_many_arguments)]
fn run_add_via_rpc(
branch_name: Option<&str>,
auto_name: bool,
prompt_args: &PromptArgs,
setup: &SetupFlags,
rescue: &RescueArgs,
multi: &MultiArgs,
base: Option<&str>,
pr: Option<u32>,
name: Option<&str>,
wait: bool,
session: bool,
) -> Result<()> {
use crate::sandbox::rpc::{RpcClient, RpcRequest, RpcResponse};
use crate::workflow::prompt_loader::{PromptLoadArgs, load_prompt};
if base.is_some() {
bail!("--base is not supported from inside a sandbox");
}
if pr.is_some() {
bail!("--pr is not supported from inside a sandbox");
}
if name.is_some() {
bail!("--name is not supported from inside a sandbox");
}
if wait {
bail!("--wait is not supported from inside a sandbox");
}
if rescue.with_changes {
bail!("--with-changes is not supported from inside a sandbox");
}
if !multi.agent.is_empty() {
bail!("--agent is not supported from inside a sandbox (uses host config)");
}
if multi.count.is_some() {
bail!(
"--count is not supported from inside a sandbox. Call workmux add multiple times instead."
);
}
if multi.foreach.is_some() {
bail!("--foreach is not supported from inside a sandbox");
}
if session {
bail!(
"--session is not supported from inside a sandbox (host controls mode via its config)"
);
}
if prompt_args.prompt_file_only {
bail!("--prompt-file-only is not supported from inside a sandbox");
}
let prompt_content = load_prompt(&PromptLoadArgs {
prompt_editor: prompt_args.prompt_editor,
prompt_inline: prompt_args.prompt.as_deref(),
prompt_file: prompt_args.prompt_file.as_ref(),
})?;
let prompt_text = match prompt_content {
Some(Prompt::Inline(text)) => Some(text),
Some(Prompt::FromFile(path)) => Some(
std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read prompt file: {}", path.display()))?,
),
None => None,
};
let rpc_branch = if auto_name {
None
} else {
branch_name.map(|s| s.to_string())
};
let mut client = RpcClient::from_env().context(
"Failed to connect to host RPC server. Is this running inside a workmux sandbox?",
)?;
let resp = client.call(&RpcRequest::SpawnAgent {
prompt: prompt_text.unwrap_or_default(),
branch_name: rpc_branch.clone(),
background: if setup.background { Some(true) } else { None },
})?;
match resp {
RpcResponse::Ok => {
let display_name = rpc_branch.as_deref().unwrap_or("(auto-named)");
println!("✓ Spawned agent: {}", display_name);
Ok(())
}
RpcResponse::Error { message } => {
bail!("Host failed to spawn agent: {}", message)
}
other => bail!("Unexpected RPC response: {:?}", other),
}
}