use std::io::IsTerminal;
use std::path::Path;
use std::process::Command as StdCommand;
use std::time::SystemTime;
use clap::Parser;
use dialoguer::Confirm;
use git_paw::agents;
use git_paw::broker;
use git_paw::broker::messages::BrokerMessage;
#[cfg(test)]
use git_paw::broker::publish::build_status_message;
use git_paw::broker::publish::publish_to_broker_http;
use git_paw::cli::{Cli, Command, SpecsFormat};
use git_paw::config::{self, PawConfig, SupervisorConfig};
use git_paw::detect;
use git_paw::error::PawError;
use git_paw::git;
use git_paw::interactive;
use git_paw::session::{self, Session, SessionMode, SessionStatus, WorktreeEntry};
use git_paw::tmux;
fn main() {
let args = Cli::parse();
let command = args.command.unwrap_or(Command::Start {
cli: None,
branches: None,
from_all_specs: false,
specs: None,
specs_format: None,
dry_run: false,
preset: None,
supervisor: false,
no_supervisor: false,
force: false,
no_rebase: false,
});
if let Err(err) = run(command) {
err.exit();
}
}
fn run(command: Command) -> Result<(), PawError> {
match command {
Command::Start {
cli: cli_flag,
branches: branches_flag,
from_all_specs,
specs,
specs_format,
dry_run,
preset,
supervisor,
no_supervisor,
force,
no_rebase,
} => {
let supervisor_enabled =
resolve_supervisor_mode_from_cwd(no_supervisor, supervisor, dry_run)?;
let spec_mode = SpecMode::from_flags(from_all_specs, specs.as_deref());
let specs_format_str = specs_format.map(SpecsFormat::as_str);
match resolve_dispatch_target(&spec_mode, supervisor_enabled) {
DispatchTarget::Supervisor { use_specs } => {
let cwd = std::env::current_dir().map_err(|e| {
PawError::SessionError(format!("cannot read current directory: {e}"))
})?;
let repo_root = git::validate_repo(&cwd)?;
let config = config::load_config(&repo_root, None)?;
let supervisor_branches = if use_specs {
None
} else {
branches_flag.as_deref()
};
cmd_supervisor(
&repo_root,
&config,
cli_flag.as_deref(),
supervisor_branches,
specs_format_str,
dry_run,
no_rebase,
)
}
DispatchTarget::StartWithSpecs(mode) => cmd_start_with_specs(
cli_flag.as_deref(),
&mode,
specs_format_str,
dry_run,
force,
no_rebase,
),
DispatchTarget::Start => cmd_start(
cli_flag,
branches_flag,
dry_run,
preset.as_deref(),
no_supervisor,
no_rebase,
),
}
}
Command::Pause => cmd_pause(),
Command::Stop { force } => cmd_stop(force),
Command::Purge { force } => cmd_purge(force),
Command::Status => cmd_status(),
Command::ListClis => cmd_list_clis(),
Command::AddCli {
name,
command,
display_name,
} => cmd_add_cli(&name, &command, display_name.as_deref()),
Command::RemoveCli { name } => cmd_remove_cli(&name),
Command::Dashboard => cmd_dashboard(),
Command::Init => git_paw::init::run_init(),
Command::Replay {
branch,
list,
color,
session,
} => cmd_replay(branch, list, color, session.as_deref()),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SpecMode {
None,
All,
Picker,
Narrow(Vec<String>),
}
impl SpecMode {
pub fn from_flags(from_all_specs: bool, specs: Option<&[String]>) -> Self {
if from_all_specs {
return Self::All;
}
match specs {
None => Self::None,
Some([]) => Self::Picker,
Some(values) => Self::Narrow(values.to_vec()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum DispatchTarget {
Supervisor { use_specs: bool },
StartWithSpecs(SpecMode),
Start,
}
fn resolve_dispatch_target(spec_mode: &SpecMode, supervisor_enabled: bool) -> DispatchTarget {
match (supervisor_enabled, spec_mode) {
(true, SpecMode::None) => DispatchTarget::Supervisor { use_specs: false },
(true, SpecMode::All | SpecMode::Picker | SpecMode::Narrow(_)) => {
DispatchTarget::Supervisor { use_specs: true }
}
(false, SpecMode::All | SpecMode::Picker | SpecMode::Narrow(_)) => {
DispatchTarget::StartWithSpecs(spec_mode.clone())
}
(false, SpecMode::None) => DispatchTarget::Start,
}
}
fn apply_spec_mode(
mode: &SpecMode,
discovered: Vec<git_paw::specs::SpecEntry>,
prompter: &dyn interactive::Prompter,
) -> Result<Vec<git_paw::specs::SpecEntry>, PawError> {
match mode {
SpecMode::None | SpecMode::All => Ok(discovered),
SpecMode::Picker => {
if !is_interactive_stdin() {
return Err(PawError::SpecError(
"--specs without values requires an interactive terminal\n \
Use `--specs NAME[,NAME...]` to narrow explicitly, or\n \
`--from-all-specs` to launch every discovered spec."
.to_string(),
));
}
prompter.select_specs(&discovered)
}
SpecMode::Narrow(names) => git_paw::specs::resolve::resolve_specs(&discovered, names),
}
}
fn is_interactive_stdin() -> bool {
std::io::stdin().is_terminal()
}
fn attach_or_print_hint(session_name: &str) -> Result<(), PawError> {
if is_interactive_stdin() {
tmux::attach(session_name)
} else {
println!("Session '{session_name}' started in detached mode.");
println!("Attach with: tmux attach -t {session_name}");
Ok(())
}
}
pub(crate) fn build_task_prompt(spec_entry: Option<&git_paw::specs::SpecEntry>) -> String {
use git_paw::specs::SpecBackendKind;
match spec_entry {
Some(s) => match s.backend {
SpecBackendKind::OpenSpec => format!("/opsx:apply {id}", id = s.id),
SpecBackendKind::Markdown | SpecBackendKind::SpecKit => format!(
"Begin your assigned task. The full spec is in AGENTS.md in this worktree. \
Additional artifacts (proposal, design, specs, tasks) live under \
openspec/changes/{id}/ — read them all before starting.",
id = s.id,
),
},
None => "Begin your assigned task as described in AGENTS.md.".to_string(),
}
}
fn resolve_supervisor_mode_from_cwd(
no_supervisor_flag: bool,
supervisor_flag: bool,
dry_run: bool,
) -> Result<bool, PawError> {
if no_supervisor_flag {
return Ok(false);
}
if supervisor_flag {
return Ok(true);
}
let Ok(cwd) = std::env::current_dir() else {
return Ok(false);
};
let Ok(repo_root) = git::validate_repo(&cwd) else {
return Ok(false);
};
let config = config::load_config(&repo_root, None).unwrap_or_default();
resolve_supervisor_mode(
no_supervisor_flag,
supervisor_flag,
dry_run,
&config,
&mut TerminalSupervisorPrompt,
)
}
trait SupervisorPrompt {
fn ask(&mut self) -> Result<bool, PawError>;
}
struct TerminalSupervisorPrompt;
impl SupervisorPrompt for TerminalSupervisorPrompt {
fn ask(&mut self) -> Result<bool, PawError> {
if !std::io::stdin().is_terminal() {
return Ok(false);
}
Confirm::new()
.with_prompt("Start in supervisor mode?")
.default(false)
.interact()
.map_err(|e| PawError::SessionError(format!("supervisor prompt failed: {e}")))
}
}
fn resolve_supervisor_mode(
no_supervisor_flag: bool,
supervisor_flag: bool,
dry_run: bool,
config: &PawConfig,
prompt: &mut dyn SupervisorPrompt,
) -> Result<bool, PawError> {
if no_supervisor_flag {
return Ok(false);
}
if supervisor_flag {
return Ok(true);
}
if let Some(cfg) = &config.supervisor {
return Ok(cfg.enabled);
}
if dry_run {
return Ok(false);
}
prompt.ask()
}
fn config_to_custom_defs(config: &PawConfig) -> Vec<detect::CustomCliDef> {
config
.clis
.iter()
.map(|(name, cli)| detect::CustomCliDef {
name: name.clone(),
command: cli.command.clone(),
display_name: cli.display_name.clone(),
})
.collect()
}
fn to_interactive_cli(cli: &detect::CliInfo) -> interactive::CliInfo {
interactive::CliInfo {
display_name: cli.display_name.clone(),
binary_name: cli.binary_name.clone(),
}
}
#[allow(clippy::too_many_lines)]
fn cmd_start(
cli_flag: Option<String>,
branches_flag: Option<Vec<String>>,
dry_run: bool,
preset: Option<&str>,
no_supervisor: bool,
no_rebase: bool,
) -> Result<(), PawError> {
let cwd = std::env::current_dir()
.map_err(|e| PawError::SessionError(format!("cannot read current directory: {e}")))?;
let repo_root = git::validate_repo(&cwd)?;
let existing_session = session::find_session_for_repo(&repo_root)?;
if !dry_run && let Some(existing) = &existing_session {
let effective =
existing.effective_status(|name| tmux::is_session_alive(name).unwrap_or(false));
match effective {
SessionStatus::Paused => {
println!(
"Restarting paused session '{}' (broker + reattach)...",
existing.session_name
);
return restart_from_pause(&repo_root, existing);
}
SessionStatus::Active => {
println!("Reattaching to session '{}'...", existing.session_name);
return attach_or_print_hint(&existing.session_name);
}
SessionStatus::Stopped => {
println!("Recovering session '{}'...", existing.session_name);
return recover_session(&repo_root, existing);
}
}
}
tmux::ensure_tmux_installed()?;
let config = config::load_config(&repo_root, None)?;
if !no_supervisor && config.supervisor.as_ref().is_some_and(|s| s.enabled) {
return cmd_supervisor(
&repo_root,
&config,
cli_flag.as_deref(),
branches_flag.as_deref(),
None,
dry_run,
no_rebase,
);
}
let custom_defs = config_to_custom_defs(&config);
let (resolved_cli, resolved_branches) = if let Some(preset_name) = preset {
let p = config
.get_preset(preset_name)
.ok_or_else(|| PawError::ConfigError(format!("preset '{preset_name}' not found")))?;
(Some(p.cli.clone()), Some(p.branches.clone()))
} else {
(cli_flag, branches_flag)
};
let detected = detect::detect_clis(&custom_defs);
if detected.is_empty() {
return Err(PawError::NoCLIsFound);
}
let all_branches = git::list_branches(&repo_root)?;
let interactive_clis: Vec<interactive::CliInfo> =
detected.iter().map(to_interactive_cli).collect();
let prompter = interactive::TerminalPrompter;
let selection = interactive::run_selection(
&prompter,
&all_branches,
&interactive_clis,
resolved_cli.as_deref(),
resolved_branches.as_deref(),
)?;
let project = git::project_name(&repo_root);
let mouse = config.mouse.unwrap_or(true);
let session_name = tmux::resolve_session_name(&project)?;
if dry_run {
if let Some(ref existing) = existing_session {
eprintln!(
"warning: session '{}' already exists — purge it before starting a new one\n",
existing.session_name
);
}
println!("Dry run — session plan:\n");
println!(" Session: {session_name}");
println!(" Mouse: {}", if mouse { "on" } else { "off" });
println!();
for (branch, cli) in &selection.mappings {
let wt_dir = git::worktree_dir_name(&project, branch);
println!(" {branch} \u{2192} {cli} (../{wt_dir})");
}
return Ok(());
}
git::prune_worktrees(&repo_root)?;
let broker_config = config.broker.clone();
let mut builder = tmux::TmuxSessionBuilder::new(&project)
.session_name(session_name)
.mouse_mode(mouse);
if broker_config.enabled {
let repo_str = repo_root.to_string_lossy().to_string();
builder = builder.add_pane(tmux::PaneSpec {
branch: "dashboard".to_string(),
worktree: repo_str,
cli_command: format!(
"{} __dashboard",
std::env::current_exe()
.unwrap_or_else(|_| std::path::PathBuf::from("git-paw"))
.display()
),
});
builder = builder.set_environment("GIT_PAW_BROKER_URL", &broker_config.url());
}
let mut worktree_entries = Vec::new();
let skill_content = if broker_config.enabled {
let template = git_paw::skills::resolve("coordination")?;
Some(template)
} else {
None
};
for (branch, cli) in &selection.mappings {
let wt = git::create_worktree(&repo_root, branch, !no_rebase)?;
let wt_str = wt.path.to_string_lossy().to_string();
let rendered_skill = skill_content.as_ref().map(|tmpl| {
git_paw::skills::render(
tmpl,
branch,
&broker_config.url(),
&project,
&git_paw::skills::GateCommands::default(),
)
});
let assignment = git_paw::agents::WorktreeAssignment {
branch: branch.clone(),
cli: cli.clone(),
spec_content: None,
owned_files: None,
skill_content: rendered_skill,
inter_agent_rules: None,
};
git_paw::agents::setup_worktree_agents_md(&repo_root, &wt.path, &assignment)?;
if broker_config.enabled {
let agent_id = git_paw::broker::messages::slugify_branch(branch);
git_paw::agents::install_git_hooks(&wt.path, &broker_config.url(), &agent_id)?;
}
builder = builder.add_pane(tmux::PaneSpec {
branch: branch.clone(),
worktree: wt_str,
cli_command: cli.clone(),
});
worktree_entries.push(WorktreeEntry {
branch: branch.clone(),
worktree_path: wt.path,
cli: cli.clone(),
branch_created: wt.branch_created,
});
}
let tmux_session = builder.build()?;
tmux_session.execute()?;
if broker_config.enabled {
for (idx, (branch, _)) in selection.mappings.iter().enumerate() {
let pane_idx = if broker_config.enabled { idx + 1 } else { idx };
let boot_block = git_paw::skills::build_boot_block(branch, &broker_config.url());
let args =
git_paw::tmux::build_boot_inject_args(&tmux_session.name, pane_idx, &boot_block);
let _ = std::process::Command::new("tmux").args(&args).status();
}
}
let mut state = Session {
session_name: tmux_session.name.clone(),
repo_path: repo_root,
project_name: project,
created_at: SystemTime::now(),
status: SessionStatus::Active,
worktrees: worktree_entries,
broker_port: None,
broker_bind: None,
broker_log_path: None,
mode: SessionMode::Bare,
dashboard_pane: None,
};
if broker_config.enabled {
state.broker_port = Some(broker_config.port);
state.broker_bind = Some(broker_config.bind.clone());
state.broker_log_path = Some(session::session_state_dir()?.join("broker.log"));
state.dashboard_pane = Some(0);
}
session::save_session(&state)?;
attach_or_print_hint(&tmux_session.name)
}
#[cfg_attr(not(test), expect(dead_code))]
fn publish_supervisor_question(question: &str, broker_url: &str) -> Result<(), PawError> {
let msg = BrokerMessage::Question {
agent_id: "supervisor".to_string(),
payload: git_paw::broker::messages::QuestionPayload {
question: question.to_string(),
},
};
publish_to_broker_http(broker_url, &msg)
}
fn spawn_auto_approve_thread(
session_name: String,
broker_url: String,
config: Option<config::AutoApproveConfig>,
pane_map: std::collections::HashMap<String, usize>,
) -> Option<(
std::sync::Arc<std::sync::atomic::AtomicBool>,
std::thread::JoinHandle<()>,
)> {
let cfg = config?.resolved();
if !cfg.enabled {
return None;
}
let period = std::time::Duration::from_secs(
cfg.stall_threshold_seconds
.max(git_paw::config::AutoApproveConfig::MIN_STALL_THRESHOLD_SECONDS),
);
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let stop_clone = stop.clone();
let handle = std::thread::spawn(move || {
use git_paw::supervisor::approve::TmuxKeyDispatcher;
use git_paw::supervisor::poll::{
PollContext, TmuxPaneInspector, fetch_status_over_http, tick_from_status,
};
struct BrokerForwarder {
broker_url: String,
}
impl git_paw::supervisor::poll::QuestionForwarder for BrokerForwarder {
fn forward_question(
&mut self,
agent_id: &str,
kind: git_paw::supervisor::permission_prompt::PermissionType,
_captured: &str,
) {
let question = format!(
"{agent_id} is stalled on a permission prompt classified as {kind:?}; \
please review the pane and decide manually."
);
let msg = git_paw::broker::messages::BrokerMessage::Question {
agent_id: "supervisor".to_string(),
payload: git_paw::broker::messages::QuestionPayload { question },
};
if let Err(e) =
git_paw::broker::publish::publish_to_broker_http(&self.broker_url, &msg)
{
eprintln!("auto-approve: failed to forward question to dashboard: {e}");
}
}
}
let inspector = TmuxPaneInspector;
let resolver = move |id: &str| pane_map.get(id).copied();
let mut dispatcher = TmuxKeyDispatcher;
let mut forwarder = BrokerForwarder {
broker_url: broker_url.clone(),
};
while !stop_clone.load(std::sync::atomic::Ordering::Relaxed) {
std::thread::sleep(period);
if stop_clone.load(std::sync::atomic::Ordering::Relaxed) {
break;
}
let rows = match fetch_status_over_http(&broker_url) {
Ok(rows) => rows,
Err(e) => {
eprintln!("auto-approve: broker /status fetch failed: {e}");
continue;
}
};
let mut ctx = PollContext {
state: None,
session: &session_name,
config: &cfg,
resolver: &resolver,
inspector: &inspector,
dispatcher: &mut dispatcher,
forwarder: &mut forwarder,
broker_url: Some(&broker_url),
};
let _ = tick_from_status(&rows, &mut ctx);
}
});
Some((stop, handle))
}
#[allow(clippy::too_many_lines)]
fn cmd_supervisor(
repo_root: &Path,
config: &PawConfig,
cli_flag: Option<&str>,
branches_flag: Option<&[String]>,
specs_format_override: Option<&str>,
dry_run: bool,
no_rebase: bool,
) -> Result<(), PawError> {
let default_supervisor_cfg = SupervisorConfig::default();
let supervisor_cfg = config
.supervisor
.as_ref()
.unwrap_or(&default_supervisor_cfg);
let supervisor_cli = supervisor_cfg
.cli
.clone()
.or_else(|| config.default_cli.clone())
.ok_or_else(|| {
PawError::ConfigError(
"supervisor mode requires either [supervisor].cli or default_cli to be set"
.to_string(),
)
})?;
let agent_cli = cli_flag
.map(ToString::to_string)
.or_else(|| config.default_cli.clone())
.unwrap_or_else(|| supervisor_cli.clone());
let mut spec_by_branch: std::collections::HashMap<String, git_paw::specs::SpecEntry> =
std::collections::HashMap::new();
let branches: Vec<String> = if let Some(bs) = branches_flag {
bs.to_vec()
} else {
let specs =
git_paw::specs::scan_specs_with_override(config, repo_root, specs_format_override)?;
if specs.is_empty() {
return Err(PawError::ConfigError(
"supervisor mode found no branches: pass --branches or define specs".to_string(),
));
}
let mut out = Vec::with_capacity(specs.len());
for spec in specs {
out.push(spec.branch.clone());
spec_by_branch.insert(spec.branch.clone(), spec);
}
out
};
let project = git::project_name(repo_root);
let session_name = tmux::resolve_session_name(&project)?;
let mouse = config.mouse.unwrap_or(true);
let broker_config = config.broker.clone();
let approval = &supervisor_cfg.agent_approval;
let agent_flags = config::approval_flags(&agent_cli, approval);
let supervisor_flags = config::approval_flags(&supervisor_cli, approval);
if dry_run {
println!("Dry run — supervisor session plan:\n");
println!(" Session: {session_name}");
println!(" Supervisor: {supervisor_cli}");
println!(" Agent CLI: {agent_cli}");
println!(" Approval: {approval:?}");
println!(" Mouse: {}", if mouse { "on" } else { "off" });
if broker_config.enabled {
println!(" Broker URL: {}", broker_config.url());
}
println!();
for branch in &branches {
let wt_dir = git::worktree_dir_name(&project, branch);
let cmd = if agent_flags.is_empty() {
agent_cli.clone()
} else {
format!("{agent_cli} {agent_flags}")
};
println!(" {branch} \u{2192} {cmd} (../{wt_dir})");
}
return Ok(());
}
let layout = git_paw::supervisor::layout::supervisor_layout(branches.len())?;
git::prune_worktrees(repo_root)?;
if broker_config.enabled {
let claude_settings = repo_root.join(".claude").join("settings.json");
if let Err(e) = git_paw::supervisor::curl_allowlist::setup_curl_allowlist(
&broker_config.url(),
&claude_settings,
) {
eprintln!("warning: failed to setup curl allowlist: {e}");
}
}
if supervisor_cfg.common_dev_allowlist.enabled {
for (path, err) in git_paw::supervisor::dev_allowlist::seed_supervisor_session(
&supervisor_cfg.common_dev_allowlist.extra,
repo_root,
) {
eprintln!(
"warning: failed to seed dev allowlist into {}: {err}",
path.display(),
);
}
}
let supervisor_skill_template = git_paw::skills::resolve("supervisor")?;
let supervisor_md = git_paw::skills::render(
&supervisor_skill_template,
"supervisor",
&broker_config.url(),
&project,
&supervisor_cfg.gate_commands(),
);
let supervisor_assignment = git_paw::agents::WorktreeAssignment {
branch: "supervisor".to_string(),
cli: supervisor_cli.clone(),
spec_content: None,
owned_files: None,
skill_content: Some(supervisor_md),
inter_agent_rules: None,
};
git_paw::agents::setup_worktree_agents_md(repo_root, repo_root, &supervisor_assignment)?;
let coordination_template = if broker_config.enabled {
Some(git_paw::skills::resolve("coordination")?)
} else {
None
};
let branch_refs: Vec<&str> = branches.iter().map(String::as_str).collect();
let inter_agent_rules = git_paw::agents::build_inter_agent_rules(&branch_refs);
let repo_str = repo_root.to_string_lossy().to_string();
let dashboard_command = format!(
"{} __dashboard",
std::env::current_exe()
.unwrap_or_else(|_| std::path::PathBuf::from("git-paw"))
.display()
);
let supervisor_cli_command = if supervisor_flags.is_empty() {
supervisor_cli.clone()
} else {
format!("{supervisor_cli} {supervisor_flags}")
};
let supervisor_pane = tmux::PaneSpec {
branch: "supervisor".to_string(),
worktree: repo_str.clone(),
cli_command: supervisor_cli_command,
};
let dashboard_pane = tmux::PaneSpec {
branch: "dashboard".to_string(),
worktree: repo_str,
cli_command: dashboard_command,
};
let mut agent_panes: Vec<tmux::PaneSpec> = Vec::with_capacity(branches.len());
let mut agent_prompts: Vec<String> = Vec::with_capacity(branches.len());
let mut worktree_entries: Vec<WorktreeEntry> = Vec::with_capacity(branches.len());
for branch in &branches {
let wt = git::create_worktree(repo_root, branch, !no_rebase)?;
let wt_str = wt.path.to_string_lossy().to_string();
let rendered_skill = coordination_template.as_ref().map(|tmpl| {
git_paw::skills::render(
tmpl,
branch,
&broker_config.url(),
&project,
&supervisor_cfg.gate_commands(),
)
});
let spec_entry = spec_by_branch.get(branch);
let spec_content = spec_entry.map(|s| s.prompt.clone());
let owned_files = spec_entry.and_then(|s| s.owned_files.clone());
let assignment = git_paw::agents::WorktreeAssignment {
branch: branch.clone(),
cli: agent_cli.clone(),
spec_content,
owned_files,
skill_content: rendered_skill,
inter_agent_rules: Some(inter_agent_rules.clone()),
};
git_paw::agents::setup_worktree_agents_md(repo_root, &wt.path, &assignment)?;
if broker_config.enabled {
let agent_id = git_paw::broker::messages::slugify_branch(branch);
git_paw::agents::install_git_hooks(&wt.path, &broker_config.url(), &agent_id)?;
}
let cli_command = if agent_flags.is_empty() {
agent_cli.clone()
} else {
format!("{agent_cli} {agent_flags}")
};
agent_panes.push(tmux::PaneSpec {
branch: branch.clone(),
worktree: wt_str,
cli_command,
});
let boot_block = git_paw::skills::build_boot_block(branch, &broker_config.url());
let task_prompt = build_task_prompt(spec_entry);
agent_prompts.push(format!("{boot_block}\n\n{task_prompt}"));
worktree_entries.push(WorktreeEntry {
branch: branch.clone(),
worktree_path: wt.path,
cli: agent_cli.clone(),
branch_created: wt.branch_created,
});
}
let env_vars: Vec<(String, String)> = if broker_config.enabled {
vec![("GIT_PAW_BROKER_URL".to_string(), broker_config.url())]
} else {
Vec::new()
};
let tmux_session = tmux::build_supervisor_session(
&project,
Some(session_name.clone()),
&supervisor_pane,
&dashboard_pane,
&agent_panes,
layout,
mouse,
&env_vars,
)?;
tmux_session.execute()?;
let mut state = Session {
session_name: tmux_session.name.clone(),
repo_path: repo_root.to_path_buf(),
project_name: project.clone(),
created_at: SystemTime::now(),
status: SessionStatus::Active,
worktrees: worktree_entries,
broker_port: None,
broker_bind: None,
broker_log_path: None,
mode: SessionMode::Supervisor,
dashboard_pane: None,
};
if broker_config.enabled {
state.broker_port = Some(broker_config.port);
state.broker_bind = Some(broker_config.bind.clone());
state.broker_log_path = Some(session::session_state_dir()?.join("broker.log"));
state.dashboard_pane = Some(1);
}
session::save_session(&state)?;
std::thread::sleep(std::time::Duration::from_secs(2));
let supervisor_boot_block =
git_paw::skills::build_boot_block("supervisor", &broker_config.url());
let supervisor_framing =
"Begin observing the spec implementation session. Your skill (AGENTS.md) describes \
your role — read it, then start the autonomous loop. The user can type questions or \
directives directly into your pane; handle them per the 'When the user types in your \
pane' section of your skill."
.to_string();
let supervisor_prompt = format!("{supervisor_boot_block}\n\n{supervisor_framing}");
submit_prompt_to_pane(&tmux_session.name, 0, &supervisor_prompt);
for (idx, prompt) in agent_prompts.iter().enumerate() {
let pane_idx = git_paw::supervisor::layout::SUPERVISOR_PANE_OFFSET + idx;
submit_prompt_to_pane(&tmux_session.name, pane_idx, prompt);
}
println!(
"Supervisor session '{}' launched with {} coding agent(s).",
tmux_session.name,
branches.len()
);
println!("Attach with: tmux attach -t {}", tmux_session.name);
Ok(())
}
fn submit_prompt_to_pane(session_name: &str, pane_idx: usize, prompt: &str) {
let target = format!("{session_name}:0.{pane_idx}");
let _ = std::process::Command::new("tmux")
.args(["send-keys", "-t", &target, prompt, "Enter"])
.status();
}
fn cmd_start_with_specs(
cli_flag: Option<&str>,
spec_mode: &SpecMode,
specs_format_override: Option<&str>,
dry_run: bool,
force: bool,
no_rebase: bool,
) -> Result<(), PawError> {
let cwd = std::env::current_dir()
.map_err(|e| PawError::SessionError(format!("cannot read current directory: {e}")))?;
let repo_root = git::validate_repo(&cwd)?;
let existing_session = session::find_session_for_repo(&repo_root)?;
if !dry_run && let Some(existing) = &existing_session {
let effective =
existing.effective_status(|name| tmux::is_session_alive(name).unwrap_or(false));
match effective {
SessionStatus::Paused => {
println!(
"Restarting paused session '{}' (broker + reattach)...",
existing.session_name
);
return restart_from_pause(&repo_root, existing);
}
SessionStatus::Active => {
println!("Reattaching to session '{}'...", existing.session_name);
return attach_or_print_hint(&existing.session_name);
}
SessionStatus::Stopped => {
println!("Recovering session '{}'...", existing.session_name);
return recover_session(&repo_root, existing);
}
}
}
tmux::ensure_tmux_installed()?;
let config = config::load_config(&repo_root, None)?;
let discovered =
git_paw::specs::scan_specs_with_override(&config, &repo_root, specs_format_override)?;
if discovered.is_empty() {
println!("No pending specs found.");
return Ok(());
}
let specs = apply_spec_mode(spec_mode, discovered, &interactive::TerminalPrompter)?;
if specs.is_empty() {
println!("No pending specs found.");
return Ok(());
}
let uncommitted_specs = git::check_uncommitted_specs(&repo_root, &specs)?;
if !uncommitted_specs.is_empty() && !force {
eprintln!(
"warning: Uncommitted spec changes detected in: {}\n Commit your changes or use --force to proceed",
uncommitted_specs.join(", ")
);
} else if !uncommitted_specs.is_empty() && force {
eprintln!("Proceeding with --force flag (uncommitted spec changes ignored)");
}
let custom_defs = config_to_custom_defs(&config);
let detected = detect::detect_clis(&custom_defs);
if detected.is_empty() {
return Err(PawError::NoCLIsFound);
}
let interactive_clis: Vec<interactive::CliInfo> =
detected.iter().map(to_interactive_cli).collect();
let prompter = interactive::TerminalPrompter;
let mappings = interactive::resolve_cli_for_specs(
&specs,
cli_flag,
&config,
&interactive_clis,
&prompter,
)?;
let spec_by_branch: std::collections::HashMap<&str, &git_paw::specs::SpecEntry> =
specs.iter().map(|s| (s.branch.as_str(), s)).collect();
let project = git::project_name(&repo_root);
let mouse = config.mouse.unwrap_or(true);
let session_name = tmux::resolve_session_name(&project)?;
if dry_run {
if let Some(ref existing) = existing_session {
eprintln!(
"warning: session '{}' already exists — purge it before starting a new one\n",
existing.session_name
);
}
println!("Dry run — session plan (from specs):\n");
println!(" Session: {session_name}");
println!(" Mouse: {}", if mouse { "on" } else { "off" });
println!();
for (branch, cli) in &mappings {
let wt_dir = git::worktree_dir_name(&project, branch);
println!(" {branch} \u{2192} {cli} (../{wt_dir})");
}
return Ok(());
}
launch_spec_session(
&repo_root,
&config,
&mappings,
&spec_by_branch,
&project,
mouse,
no_rebase,
)
}
#[allow(clippy::too_many_lines)]
fn launch_spec_session(
repo_root: &std::path::Path,
config: &PawConfig,
mappings: &[(String, String)],
spec_by_branch: &std::collections::HashMap<&str, &git_paw::specs::SpecEntry>,
project: &str,
mouse: bool,
no_rebase: bool,
) -> Result<(), PawError> {
let session_name = tmux::resolve_session_name(project)?;
git::prune_worktrees(repo_root)?;
let broker_config = config.broker.clone();
let mut builder = tmux::TmuxSessionBuilder::new(project)
.session_name(session_name)
.mouse_mode(mouse);
if broker_config.enabled {
let repo_str = repo_root.to_string_lossy().to_string();
builder = builder.add_pane(tmux::PaneSpec {
branch: "dashboard".to_string(),
worktree: repo_str,
cli_command: format!(
"{} __dashboard",
std::env::current_exe()
.unwrap_or_else(|_| std::path::PathBuf::from("git-paw"))
.display()
),
});
builder = builder.set_environment("GIT_PAW_BROKER_URL", &broker_config.url());
}
let skill_template = if broker_config.enabled {
Some(git_paw::skills::resolve("coordination")?)
} else {
None
};
let mut worktree_entries = Vec::new();
for (branch, cli) in mappings {
let wt = git::create_worktree(repo_root, branch, !no_rebase)?;
let wt_str = wt.path.to_string_lossy().to_string();
let rendered_skill = skill_template.as_ref().map(|tmpl| {
git_paw::skills::render(
tmpl,
branch,
&broker_config.url(),
project,
&config
.supervisor
.as_ref()
.map(|s| s.gate_commands())
.unwrap_or_default(),
)
});
let spec_content = spec_by_branch
.get(branch.as_str())
.map(|s| s.prompt.clone());
let owned_files = spec_by_branch
.get(branch.as_str())
.and_then(|s| s.owned_files.clone());
let assignment = git_paw::agents::WorktreeAssignment {
branch: branch.clone(),
cli: cli.clone(),
spec_content,
owned_files,
skill_content: rendered_skill,
inter_agent_rules: None,
};
git_paw::agents::setup_worktree_agents_md(repo_root, &wt.path, &assignment)?;
if broker_config.enabled {
let agent_id = git_paw::broker::messages::slugify_branch(branch);
git_paw::agents::install_git_hooks(&wt.path, &broker_config.url(), &agent_id)?;
}
builder = builder.add_pane(tmux::PaneSpec {
branch: branch.clone(),
worktree: wt_str,
cli_command: cli.clone(),
});
worktree_entries.push(WorktreeEntry {
branch: branch.clone(),
worktree_path: wt.path,
cli: cli.clone(),
branch_created: wt.branch_created,
});
}
let mut tmux_session = builder.build()?;
if config.logging.as_ref().is_some_and(|l| l.enabled) {
let pane_offset = usize::from(broker_config.enabled);
git_paw::logging::ensure_log_dir(repo_root, &tmux_session.name)?;
for (i, (branch, _)) in mappings.iter().enumerate() {
let log_path = git_paw::logging::log_file_path(repo_root, &tmux_session.name, branch);
let pane_target = format!("{}:{}.{}", tmux_session.name, 0, i + pane_offset);
tmux_session.pipe_pane(&pane_target, &log_path);
}
}
tmux_session.execute()?;
if broker_config.enabled {
let pane_offset = usize::from(broker_config.enabled);
for (idx, (branch, _)) in mappings.iter().enumerate() {
let pane_idx = idx + pane_offset;
let boot_block = git_paw::skills::build_boot_block(branch, &broker_config.url());
let args =
git_paw::tmux::build_boot_inject_args(&tmux_session.name, pane_idx, &boot_block);
let _ = std::process::Command::new("tmux").args(&args).status();
}
}
let mut state = Session {
session_name: tmux_session.name.clone(),
repo_path: repo_root.to_path_buf(),
project_name: project.to_string(),
created_at: SystemTime::now(),
status: SessionStatus::Active,
worktrees: worktree_entries,
broker_port: None,
broker_bind: None,
broker_log_path: None,
mode: SessionMode::Bare,
dashboard_pane: None,
};
if broker_config.enabled {
state.broker_port = Some(broker_config.port);
state.broker_bind = Some(broker_config.bind.clone());
state.broker_log_path = Some(session::session_state_dir()?.join("broker.log"));
state.dashboard_pane = Some(0);
}
session::save_session(&state)?;
attach_or_print_hint(&tmux_session.name)
}
fn restart_from_pause(repo_root: &Path, existing: &Session) -> Result<(), PawError> {
tmux::ensure_tmux_installed()?;
let dashboard_index = existing.dashboard_pane.unwrap_or(0);
if existing.broker_port.is_some() {
let dashboard_command = format!(
"{} __dashboard",
std::env::current_exe()
.unwrap_or_else(|_| std::path::PathBuf::from("git-paw"))
.display()
);
let repo_str = repo_root.to_string_lossy().to_string();
let split_status = StdCommand::new("tmux")
.args([
"split-window",
"-h",
"-b",
"-t",
&format!("{}:0.{dashboard_index}", existing.session_name),
"-c",
&repo_str,
])
.status()
.map_err(|e| PawError::TmuxError(format!("failed to spawn dashboard pane: {e}")))?;
if !split_status.success() {
return Err(PawError::TmuxError(
"failed to recreate dashboard pane".to_string(),
));
}
let target = format!("{}:0.{dashboard_index}", existing.session_name);
let title = "dashboard \u{2192} git-paw __dashboard".to_string();
let _ = StdCommand::new("tmux")
.args(["select-pane", "-t", &target, "-T", &title])
.status();
let send_status = StdCommand::new("tmux")
.args(["send-keys", "-t", &target, &dashboard_command, "Enter"])
.status()
.map_err(|e| PawError::TmuxError(format!("failed to send dashboard command: {e}")))?;
if !send_status.success() {
return Err(PawError::TmuxError(
"failed to send dashboard command".to_string(),
));
}
}
let mut updated = existing.clone();
updated.status = SessionStatus::Active;
session::save_session(&updated)?;
attach_or_print_hint(&existing.session_name)
}
fn recover_session(repo_root: &Path, existing: &Session) -> Result<(), PawError> {
tmux::ensure_tmux_installed()?;
let config = config::load_config(repo_root, None)?;
let mouse = config.mouse.unwrap_or(true);
let supervisor_enabled_in_config = config.supervisor.as_ref().is_some_and(|s| s.enabled);
let mode = if existing.mode == SessionMode::Supervisor {
SessionMode::Supervisor
} else if supervisor_enabled_in_config {
eprintln!(
"warning: session '{}' was created with a v0.4 layout but [supervisor] is enabled \
in current config — rebuilding with v0.5 supervisor-as-pane layout.",
existing.session_name
);
SessionMode::Supervisor
} else {
SessionMode::Bare
};
let broker_url = existing
.broker_port
.zip(existing.broker_bind.as_ref())
.map(|(port, bind)| format!("http://{bind}:{port}"));
if let Some(url) = &broker_url {
let claude_settings = repo_root.join(".claude").join("settings.json");
if let Err(e) =
git_paw::supervisor::curl_allowlist::setup_curl_allowlist(url, &claude_settings)
{
eprintln!("warning: failed to setup curl allowlist: {e}");
}
}
if mode == SessionMode::Supervisor
&& let Some(supervisor_cfg) = config.supervisor.as_ref()
&& supervisor_cfg.common_dev_allowlist.enabled
{
for (path, err) in git_paw::supervisor::dev_allowlist::seed_supervisor_session(
&supervisor_cfg.common_dev_allowlist.extra,
repo_root,
) {
eprintln!(
"warning: failed to seed dev allowlist into {}: {err}",
path.display(),
);
}
}
let tmux_session = match mode {
SessionMode::Supervisor => {
recover_supervisor_session(repo_root, existing, &config, broker_url.as_deref(), mouse)?
}
SessionMode::Bare => {
recover_bare_session(repo_root, existing, broker_url.as_deref(), mouse)?
}
};
tmux_session.execute()?;
let mut updated = existing.clone();
updated.status = SessionStatus::Active;
updated.mode = mode;
session::save_session(&updated)?;
attach_or_print_hint(&tmux_session.name)
}
fn recover_bare_session(
repo_root: &Path,
existing: &Session,
broker_url: Option<&str>,
mouse: bool,
) -> Result<tmux::TmuxSession, PawError> {
let mut builder = tmux::TmuxSessionBuilder::new(&existing.project_name)
.session_name(existing.session_name.clone())
.mouse_mode(mouse);
if let Some(url) = broker_url {
let repo_str = repo_root.to_string_lossy().to_string();
builder = builder.add_pane(tmux::PaneSpec {
branch: "dashboard".to_string(),
worktree: repo_str,
cli_command: format!(
"{} __dashboard",
std::env::current_exe()
.unwrap_or_else(|_| std::path::PathBuf::from("git-paw"))
.display()
),
});
builder = builder.set_environment("GIT_PAW_BROKER_URL", url);
}
for entry in &existing.worktrees {
builder = builder.add_pane(tmux::PaneSpec {
branch: entry.branch.clone(),
worktree: entry.worktree_path.to_string_lossy().to_string(),
cli_command: entry.cli.clone(),
});
}
builder.build()
}
fn recover_supervisor_session(
repo_root: &Path,
existing: &Session,
config: &PawConfig,
broker_url: Option<&str>,
mouse: bool,
) -> Result<tmux::TmuxSession, PawError> {
let default_supervisor_cfg = SupervisorConfig::default();
let supervisor_cfg = config
.supervisor
.as_ref()
.unwrap_or(&default_supervisor_cfg);
let supervisor_cli = supervisor_cfg
.cli
.clone()
.or_else(|| config.default_cli.clone())
.ok_or_else(|| {
PawError::ConfigError(
"supervisor recovery requires either [supervisor].cli or default_cli to be set"
.to_string(),
)
})?;
let supervisor_flags = config::approval_flags(&supervisor_cli, &supervisor_cfg.agent_approval);
let supervisor_cli_command = if supervisor_flags.is_empty() {
supervisor_cli
} else {
format!("{supervisor_cli} {supervisor_flags}")
};
let layout = git_paw::supervisor::layout::supervisor_layout(existing.worktrees.len())?;
let repo_str = repo_root.to_string_lossy().to_string();
let supervisor_pane = tmux::PaneSpec {
branch: "supervisor".to_string(),
worktree: repo_str.clone(),
cli_command: supervisor_cli_command,
};
let dashboard_pane = tmux::PaneSpec {
branch: "dashboard".to_string(),
worktree: repo_str,
cli_command: format!(
"{} __dashboard",
std::env::current_exe()
.unwrap_or_else(|_| std::path::PathBuf::from("git-paw"))
.display()
),
};
let agent_panes: Vec<tmux::PaneSpec> = existing
.worktrees
.iter()
.map(|entry| tmux::PaneSpec {
branch: entry.branch.clone(),
worktree: entry.worktree_path.to_string_lossy().to_string(),
cli_command: entry.cli.clone(),
})
.collect();
let env_vars: Vec<(String, String)> = broker_url
.map(|url| vec![("GIT_PAW_BROKER_URL".to_string(), url.to_string())])
.unwrap_or_default();
tmux::build_supervisor_session(
&existing.project_name,
Some(existing.session_name.clone()),
&supervisor_pane,
&dashboard_pane,
&agent_panes,
layout,
mouse,
&env_vars,
)
}
#[allow(clippy::too_many_lines)]
fn cmd_dashboard() -> Result<(), PawError> {
if std::env::var("TMUX").is_err() {
return Err(PawError::DashboardError(
"this is an internal command that should only be run by git-paw inside tmux"
.to_string(),
));
}
let cwd = std::env::current_dir()
.map_err(|e| PawError::SessionError(format!("cannot read current directory: {e}")))?;
let repo_root = git::validate_repo(&cwd)?;
let config = config::load_config(&repo_root, None)?;
let broker_config = config.broker.clone();
let show_message_log = config
.dashboard
.as_ref()
.is_some_and(|d| d.show_message_log);
let conflict_cfg = config
.supervisor
.as_ref()
.filter(|s| s.enabled)
.map(|s| s.conflict.clone());
let learnings_interval_seconds = config
.supervisor
.as_ref()
.map_or(60, |s| s.learnings_config.flush_interval_seconds);
let log_path = session::session_state_dir()?.join("broker.log");
let broker_state = broker::BrokerState::new(Some(log_path));
let saved_session = session::find_session_for_repo(&repo_root)?;
let watch_targets = saved_session
.as_ref()
.map(|sess| {
sess.worktrees
.iter()
.map(|wt| broker::WatchTarget {
agent_id: broker::messages::slugify_branch(&wt.branch),
cli: wt.cli.clone(),
worktree_path: wt.worktree_path.clone(),
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
let handle = broker::start_broker_with(
&broker_config,
broker_state,
watch_targets,
conflict_cfg,
learnings_interval_seconds,
)?;
let state = std::sync::Arc::clone(&handle.state);
let shutdown = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
#[cfg(unix)]
{
use std::sync::atomic::AtomicPtr;
static SHUTDOWN_PTR: AtomicPtr<std::sync::atomic::AtomicBool> =
AtomicPtr::new(std::ptr::null_mut());
const SIGHUP: std::ffi::c_int = 1;
unsafe extern "C" {
fn signal(signum: std::ffi::c_int, handler: extern "C" fn(std::ffi::c_int)) -> usize;
}
extern "C" fn sighup_handler(_: std::ffi::c_int) {
let ptr = SHUTDOWN_PTR.load(std::sync::atomic::Ordering::Acquire);
if !ptr.is_null() {
unsafe {
(*ptr).store(true, std::sync::atomic::Ordering::Relaxed);
}
}
}
SHUTDOWN_PTR.store(
std::sync::Arc::as_ptr(&shutdown).cast_mut(),
std::sync::atomic::Ordering::Release,
);
unsafe {
signal(SIGHUP, sighup_handler);
}
}
let auto_approve_handle = saved_session
.as_ref()
.filter(|sess| sess.mode == SessionMode::Supervisor && broker_config.enabled)
.and_then(|sess| {
let auto_approve_cfg = config
.supervisor
.as_ref()
.and_then(|s| s.auto_approve.clone())?;
let pane_map: std::collections::HashMap<String, usize> = sess
.worktrees
.iter()
.enumerate()
.map(|(idx, wt)| {
(
broker::messages::slugify_branch(&wt.branch),
idx + git_paw::supervisor::layout::SUPERVISOR_PANE_OFFSET,
)
})
.collect();
spawn_auto_approve_thread(
sess.session_name.clone(),
broker_config.url(),
Some(auto_approve_cfg),
pane_map,
)
});
let dashboard_result = git_paw::dashboard::run_dashboard_with_panes(
&state,
handle,
&shutdown,
&std::collections::HashMap::new(),
None,
show_message_log,
);
if let Some((stop, join)) = auto_approve_handle {
stop.store(true, std::sync::atomic::Ordering::Relaxed);
let _ = join.join();
}
dashboard_result
}
fn cmd_pause() -> Result<(), PawError> {
let cwd = std::env::current_dir()
.map_err(|e| PawError::SessionError(format!("cannot read current directory: {e}")))?;
let repo_root = git::validate_repo(&cwd)?;
let Some(existing) = session::find_session_for_repo(&repo_root)? else {
println!("No active session for this repo.");
return Ok(());
};
if existing.status == SessionStatus::Paused {
println!("Session '{}' is already paused.", existing.session_name);
return Ok(());
}
let effective = existing.effective_status(|name| tmux::is_session_alive(name).unwrap_or(false));
if effective == SessionStatus::Stopped {
println!(
"Session '{}' is already stopped; pause has no effect.",
existing.session_name
);
return Ok(());
}
tmux::detach_client(&existing.session_name)?;
if existing.broker_port.is_some() {
let pane_idx = existing.dashboard_pane.unwrap_or(0);
tmux::kill_pane(&existing.session_name, pane_idx)?;
}
let cli_pane_count = existing.worktrees.len();
let session_name = existing.session_name.clone();
let mut updated = existing;
updated.status = SessionStatus::Paused;
session::save_session(&updated)?;
println!(
"Session '{session_name}' paused. {cli_pane_count} CLI pane(s) still running. \
Run 'git paw start' to resume."
);
Ok(())
}
fn cmd_stop(_force: bool) -> Result<(), PawError> {
let cwd = std::env::current_dir()
.map_err(|e| PawError::SessionError(format!("cannot read current directory: {e}")))?;
let repo_root = git::validate_repo(&cwd)?;
let Some(existing) = session::find_session_for_repo(&repo_root)? else {
println!("No active session for this repo.");
return Ok(());
};
if tmux::is_session_alive(&existing.session_name)? {
tmux::kill_session(&existing.session_name)?;
}
if let Err(e) = agents::remove_session_boot_block(&repo_root) {
eprintln!("warning: failed to clean session boot block from AGENTS.md: {e}");
}
let mut updated = existing;
updated.status = SessionStatus::Stopped;
session::save_session(&updated)?;
println!("Session stopped. Worktrees and state preserved.");
println!("Run `git paw start` to recover.");
Ok(())
}
#[derive(Debug, PartialEq, Eq)]
enum PurgeOutcome {
Purged,
Cancelled,
}
fn cmd_purge(force: bool) -> Result<(), PawError> {
let cwd = std::env::current_dir()
.map_err(|e| PawError::SessionError(format!("cannot read current directory: {e}")))?;
let repo_root = git::validate_repo(&cwd)?;
let Some(existing) = session::find_session_for_repo(&repo_root)? else {
println!("No session to purge for this repo.");
return Ok(());
};
let sessions_dir = session::session_state_dir()?;
let mut confirm = |prompt: &str| -> Result<bool, PawError> {
Confirm::new()
.with_prompt(prompt)
.default(false)
.interact()
.map_err(|_| PawError::UserCancelled)
};
let mut kill_tmux = |name: &str| -> Result<(), PawError> {
if tmux::is_session_alive(name)? {
tmux::kill_session(name)?;
}
Ok(())
};
let outcome = purge_with_prompt(
&repo_root,
&sessions_dir,
&existing,
force,
&mut confirm,
&mut kill_tmux,
&mut std::io::stderr(),
)?;
match outcome {
PurgeOutcome::Purged => println!("Purged session '{}'.", existing.session_name),
PurgeOutcome::Cancelled => println!("Purge cancelled."),
}
Ok(())
}
fn purge_with_prompt(
repo_root: &Path,
sessions_dir: &Path,
session: &Session,
force: bool,
confirm: &mut dyn FnMut(&str) -> Result<bool, PawError>,
kill_tmux: &mut dyn FnMut(&str) -> Result<(), PawError>,
stderr: &mut dyn std::io::Write,
) -> Result<PurgeOutcome, PawError> {
let default_branch = resolve_default_branch(repo_root);
let unmerged = collect_unmerged_branches(repo_root, session, &default_branch);
if !unmerged.is_empty() {
let _ = writeln!(
stderr,
"Warning: {} branch(es) have unmerged commits:",
unmerged.len()
);
for (branch, count) in &unmerged {
let _ = writeln!(
stderr,
" {branch}: {count} commit(s) not in {default_branch}"
);
}
let _ = writeln!(
stderr,
"Purging is irreversible — those commits will be lost."
);
let _ = stderr.flush();
}
if !force {
let prompt_text = if unmerged.is_empty() {
"This will remove the tmux session, all worktrees, and session state. Continue?"
} else {
"Purge is irreversible. Continue?"
};
if !confirm(prompt_text)? {
return Ok(PurgeOutcome::Cancelled);
}
}
kill_tmux(&session.session_name)?;
for entry in &session.worktrees {
let _ = writeln!(
stderr,
"Removing worktree {}...",
entry.worktree_path.display()
);
let _ = stderr.flush();
let started = std::time::Instant::now();
let result = git::remove_worktree(repo_root, &entry.worktree_path);
let elapsed_secs = started.elapsed().as_secs_f64();
match result {
Ok(()) => {
let _ = writeln!(stderr, " ...done ({elapsed_secs:.1}s)");
}
Err(e) => {
let _ = writeln!(
stderr,
"warning: failed to remove worktree '{}' after {:.1}s: {e}",
entry.worktree_path.display(),
elapsed_secs
);
}
}
let _ = stderr.flush();
}
for entry in &session.worktrees {
if entry.branch_created
&& let Err(e) = git::delete_branch(repo_root, &entry.branch)
{
let _ = writeln!(
stderr,
"warning: failed to delete branch '{}': {e}",
entry.branch
);
}
}
if let Some(ref log_path) = session.broker_log_path {
let _ = std::fs::remove_file(log_path);
}
if let Err(e) = agents::remove_session_boot_block(repo_root) {
let _ = writeln!(
stderr,
"warning: failed to clean session boot block from AGENTS.md: {e}"
);
}
session::delete_session_in(&session.session_name, sessions_dir)?;
Ok(PurgeOutcome::Purged)
}
fn resolve_default_branch(repo_root: &Path) -> String {
let output = StdCommand::new("git")
.args(["symbolic-ref", "refs/remotes/origin/HEAD"])
.current_dir(repo_root)
.output();
if let Ok(out) = output
&& out.status.success()
{
let s = String::from_utf8_lossy(&out.stdout);
if let Some(name) = s.trim().strip_prefix("refs/remotes/origin/") {
return name.to_string();
}
}
"main".to_string()
}
fn collect_unmerged_branches(
repo_root: &Path,
session: &Session,
default_branch: &str,
) -> Vec<(String, usize)> {
let mut out = Vec::new();
for entry in &session.worktrees {
if entry.branch == default_branch {
continue;
}
let result = StdCommand::new("git")
.args(["log", &entry.branch, "--not", default_branch, "--oneline"])
.current_dir(repo_root)
.output();
let Ok(output) = result else { continue };
if !output.status.success() {
continue;
}
let count = String::from_utf8_lossy(&output.stdout)
.lines()
.filter(|l| !l.trim().is_empty())
.count();
if count > 0 {
out.push((entry.branch.clone(), count));
}
}
out
}
fn cmd_status() -> Result<(), PawError> {
let cwd = std::env::current_dir()
.map_err(|e| PawError::SessionError(format!("cannot read current directory: {e}")))?;
let repo_root = git::validate_repo(&cwd)?;
let Some(existing) = session::find_session_for_repo(&repo_root)? else {
println!("No session for this repo.");
return Ok(());
};
let alive = tmux::is_session_alive(&existing.session_name)?;
let effective = existing.effective_status(|name| tmux::is_session_alive(name).unwrap_or(false));
let status_icon = match effective {
SessionStatus::Active => "\u{1f7e2}", SessionStatus::Paused => "\u{1f535}", SessionStatus::Stopped => "\u{1f7e1}", };
println!("Session: {}", existing.session_name);
println!("Status: {status_icon} {effective}");
if effective == SessionStatus::Paused {
println!(" \u{21b3} run 'git paw start' to resume");
}
println!("Tmux: {}", if alive { "running" } else { "not running" });
println!();
if let (Some(bind), Some(port)) = (&existing.broker_bind, existing.broker_port) {
let url = format!("http://{bind}:{port}");
match broker::probe_broker(&url) {
broker::ProbeResult::LiveBroker => println!("Broker: {url} (running)"),
_ if effective == SessionStatus::Paused => {
println!("Broker: {url} (paused \u{2014} run 'git paw start' to resume)");
}
_ => println!("Broker: {url} (not responding)"),
}
println!();
}
if existing.worktrees.is_empty() {
println!(" (no worktrees)");
} else {
for entry in &existing.worktrees {
println!(
" {} \u{2192} {} ({})",
entry.branch,
entry.cli,
entry.worktree_path.display()
);
}
}
Ok(())
}
fn cmd_list_clis() -> Result<(), PawError> {
let cwd = std::env::current_dir()
.map_err(|e| PawError::SessionError(format!("cannot read current directory: {e}")))?;
let repo_root = git::validate_repo(&cwd)?;
let config = config::load_config(&repo_root, None)?;
let custom_defs = config_to_custom_defs(&config);
let clis = detect::detect_clis(&custom_defs);
if clis.is_empty() {
println!("No AI CLIs found.");
println!("Install one or use `git paw add-cli` to register a custom CLI.");
return Ok(());
}
println!("{:<15} {:<10} PATH", "NAME", "SOURCE");
for cli in &clis {
println!(
"{:<15} {:<10} {}",
cli.binary_name,
cli.source,
cli.path.display()
);
}
Ok(())
}
fn cmd_add_cli(name: &str, command: &str, display_name: Option<&str>) -> Result<(), PawError> {
config::add_custom_cli(name, command, display_name)?;
println!("Added custom CLI '{name}'.");
Ok(())
}
fn cmd_replay(
branch: Option<String>,
list: bool,
color: bool,
session: Option<&str>,
) -> Result<(), PawError> {
let cwd = std::env::current_dir()
.map_err(|e| PawError::SessionError(format!("cannot read current directory: {e}")))?;
let repo_root = git::validate_repo(&cwd)?;
if list {
return git_paw::replay::display_list(&repo_root);
}
let branch = branch.expect("branch is required unless --list is passed");
let session_name = git_paw::replay::resolve_session(&repo_root, session)?;
let log_path = git_paw::replay::find_log(&repo_root, &session_name, &branch)?;
if color {
git_paw::replay::replay_colored(&log_path)
} else {
git_paw::replay::replay_stripped(&log_path)
}
}
fn cmd_remove_cli(name: &str) -> Result<(), PawError> {
let cwd = std::env::current_dir()
.map_err(|e| PawError::SessionError(format!("cannot read current directory: {e}")))?;
if let Ok(repo_root) = git::validate_repo(&cwd) {
let config = config::load_config(&repo_root, None)?;
if !config.clis.contains_key(name) {
let detected = detect::detect_known_clis();
if detected.iter().any(|c| c.binary_name == name) {
return Err(PawError::CliNotFound(format!(
"CLI '{name}' is auto-detected, not a custom CLI. Cannot remove."
)));
}
}
}
config::remove_custom_cli(name)?;
println!("Removed custom CLI '{name}'.");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use git_paw::config::SupervisorConfig;
use serial_test::serial;
use std::path::PathBuf;
use std::time::UNIX_EPOCH;
use tempfile::TempDir;
#[test]
fn dispatch_from_all_specs_with_supervisor_routes_to_supervisor_with_use_specs() {
let target = resolve_dispatch_target(&SpecMode::All, true);
assert_eq!(target, DispatchTarget::Supervisor { use_specs: true });
}
#[test]
fn dispatch_from_all_specs_without_supervisor_routes_to_start_with_specs() {
let target = resolve_dispatch_target(&SpecMode::All, false);
assert_eq!(target, DispatchTarget::StartWithSpecs(SpecMode::All));
}
#[test]
fn dispatch_supervisor_without_specs_routes_to_supervisor_with_branches() {
let target = resolve_dispatch_target(&SpecMode::None, true);
assert_eq!(target, DispatchTarget::Supervisor { use_specs: false });
}
#[test]
fn dispatch_neither_flag_routes_to_start() {
let target = resolve_dispatch_target(&SpecMode::None, false);
assert_eq!(target, DispatchTarget::Start);
}
#[test]
fn dispatch_picker_routes_to_start_with_specs_picker() {
let target = resolve_dispatch_target(&SpecMode::Picker, false);
assert_eq!(target, DispatchTarget::StartWithSpecs(SpecMode::Picker));
}
#[test]
fn dispatch_narrow_routes_to_start_with_specs_narrow() {
let names = vec!["add-auth".to_string()];
let target = resolve_dispatch_target(&SpecMode::Narrow(names.clone()), false);
assert_eq!(
target,
DispatchTarget::StartWithSpecs(SpecMode::Narrow(names))
);
}
#[test]
fn dispatch_narrow_with_supervisor_routes_to_supervisor_with_use_specs() {
let names = vec!["add-auth".to_string()];
let target = resolve_dispatch_target(&SpecMode::Narrow(names), true);
assert_eq!(target, DispatchTarget::Supervisor { use_specs: true });
}
#[test]
fn dispatch_picker_with_supervisor_routes_to_supervisor_with_use_specs() {
let target = resolve_dispatch_target(&SpecMode::Picker, true);
assert_eq!(target, DispatchTarget::Supervisor { use_specs: true });
}
#[test]
fn spec_mode_from_flags_translates_each_combination() {
assert_eq!(SpecMode::from_flags(true, None), SpecMode::All);
assert_eq!(SpecMode::from_flags(false, None), SpecMode::None);
let empty: Vec<String> = Vec::new();
assert_eq!(SpecMode::from_flags(false, Some(&empty)), SpecMode::Picker);
let names = vec!["add-auth".to_string(), "fix-session".to_string()];
assert_eq!(
SpecMode::from_flags(false, Some(&names)),
SpecMode::Narrow(names)
);
}
fn make_spec_entry(id: &str, prompt_body: &str) -> git_paw::specs::SpecEntry {
git_paw::specs::SpecEntry {
id: id.to_string(),
backend: git_paw::specs::SpecBackendKind::Markdown,
branch: format!("feat/{id}"),
cli: None,
prompt: prompt_body.to_string(),
owned_files: None,
}
}
#[test]
fn task_prompt_with_spec_points_at_agents_md_and_includes_id() {
let entry = make_spec_entry("my-change", "## 1. First section\n\nbody body body");
let prompt = build_task_prompt(Some(&entry));
assert!(
prompt.contains("AGENTS.md"),
"spec-derived task prompt should point at AGENTS.md, got: {prompt}"
);
assert!(
prompt.contains("openspec/changes/my-change"),
"spec-derived task prompt should include the spec id directory, got: {prompt}"
);
}
#[test]
fn task_prompt_without_spec_uses_default_agents_md_fallback() {
let prompt = build_task_prompt(None);
assert_eq!(
prompt, "Begin your assigned task as described in AGENTS.md.",
"no-spec task prompt should be the existing default fallback verbatim"
);
}
#[test]
fn task_prompt_openspec_backend_invokes_opsx_apply_slash_command() {
let entry = git_paw::specs::SpecEntry {
id: "my-change".to_string(),
backend: git_paw::specs::SpecBackendKind::OpenSpec,
branch: "feat/my-change".to_string(),
cli: None,
prompt: String::new(),
owned_files: None,
};
let prompt = build_task_prompt(Some(&entry));
assert_eq!(
prompt, "/opsx:apply my-change",
"OpenSpec-backed task prompt should be exactly the bare slash command, got: {prompt}"
);
assert!(
!prompt.contains("AGENTS.md"),
"OpenSpec branch must suppress the AGENTS.md pointer prose, got: {prompt}"
);
assert!(
!prompt.contains("openspec/changes/"),
"OpenSpec branch must suppress the openspec/changes/ path prose, got: {prompt}"
);
}
#[test]
fn task_prompt_markdown_backend_uses_generic_agents_md_pointer() {
let entry = git_paw::specs::SpecEntry {
id: "my-feature".to_string(),
backend: git_paw::specs::SpecBackendKind::Markdown,
branch: "feat/my-feature".to_string(),
cli: None,
prompt: String::new(),
owned_files: None,
};
let prompt = build_task_prompt(Some(&entry));
assert!(
prompt.contains("AGENTS.md"),
"Markdown-backed task prompt should point at AGENTS.md, got: {prompt}"
);
assert!(
prompt.contains("openspec/changes/my-feature"),
"Markdown-backed task prompt should include the spec id directory, got: {prompt}"
);
assert!(
!prompt.contains("/opsx:apply"),
"Markdown branch must NOT invoke the OpenSpec slash command, got: {prompt}"
);
}
#[test]
fn task_prompt_without_spec_unchanged_after_backend_introduction() {
let prompt = build_task_prompt(None);
assert_eq!(
prompt, "Begin your assigned task as described in AGENTS.md.",
"no-spec task prompt should be the existing default fallback verbatim"
);
}
#[test]
fn task_prompt_does_not_include_spec_body_first_line() {
let entry = make_spec_entry(
"prompt-submit-fix",
"## 1. Code fix in cmd_supervisor\n\n- [ ] 1.1 Add a constant.\n",
);
let prompt = build_task_prompt(Some(&entry));
assert!(
!prompt.contains("## 1. Code fix in cmd_supervisor"),
"task prompt MUST NOT include the spec body's first heading in raw form, got: {prompt}"
);
assert!(
!prompt.contains("- [ ] 1.1"),
"task prompt MUST NOT include spec body content, got: {prompt}"
);
}
#[test]
fn build_task_prompt_spec_entry_contains_agents_md_and_spec_id() {
let entry = make_spec_entry("governance-config", "## 1. Struct definitions\n\nBody.");
let prompt = build_task_prompt(Some(&entry));
assert!(
prompt.contains("AGENTS.md"),
"spec-derived prompt should point at AGENTS.md, got: {prompt}"
);
assert!(
prompt.contains("openspec/changes/governance-config"),
"spec-derived prompt should embed the spec id directory, got: {prompt}"
);
assert!(
!prompt.contains("## 1. Struct definitions"),
"spec body's first heading MUST NOT leak into the prompt, got: {prompt}"
);
}
#[test]
fn supervisor_pane_prompt_starts_with_boot_block() {
let broker_url = "http://127.0.0.1:9119";
let boot_block = git_paw::skills::build_boot_block("supervisor", broker_url);
let supervisor_framing = "Begin observing the spec implementation session. Your skill (AGENTS.md) describes \
your role — read it, then start the autonomous loop. The user can type questions or \
directives directly into your pane; handle them per the 'When the user types in your \
pane' section of your skill.";
let supervisor_prompt = format!("{boot_block}\n\n{supervisor_framing}");
assert!(
supervisor_prompt.starts_with(&boot_block),
"supervisor pane prompt should begin with the boot block; got:\n{supervisor_prompt}"
);
assert!(
boot_block.contains("\"agent_id\":\"supervisor\""),
"boot block must carry the supervisor agent_id substitution; got:\n{boot_block}"
);
let framing_idx = supervisor_prompt
.find("Begin observing")
.expect("`Begin observing` framing must follow the boot block");
assert!(
framing_idx > boot_block.len(),
"framing must follow the boot block, not precede it; framing_idx={framing_idx}"
);
}
#[test]
fn supervisor_launch_records_boot_delay_constant() {
let src = include_str!("main.rs");
let cmd_start = src
.find("fn cmd_supervisor(")
.expect("cmd_supervisor signature present");
let body_start = src[cmd_start..]
.find('{')
.map(|o| cmd_start + o)
.expect("opening brace");
let mut depth: i32 = 0;
let mut end = body_start;
for (i, ch) in src[body_start..].char_indices() {
match ch {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
end = body_start + i + 1;
break;
}
}
_ => {}
}
}
let body = &src[body_start..end];
let sleep_idx = body
.find("std::thread::sleep")
.expect("cmd_supervisor must call std::thread::sleep before injecting prompts");
let snippet = &body[sleep_idx..(sleep_idx + 200).min(body.len())];
let ms = if let Some(open) = snippet.find("Duration::from_secs(") {
let after = &snippet[open + "Duration::from_secs(".len()..];
let close = after.find(')').expect("matching ) for from_secs");
let n: u64 = after[..close]
.trim()
.parse()
.expect("from_secs argument should parse as u64");
n * 1000
} else if let Some(open) = snippet.find("Duration::from_millis(") {
let after = &snippet[open + "Duration::from_millis(".len()..];
let close = after.find(')').expect("matching ) for from_millis");
after[..close]
.trim()
.parse::<u64>()
.expect("from_millis argument should parse as u64")
} else {
panic!(
"cmd_supervisor's pre-send-keys sleep should use Duration::from_secs / from_millis; got:\n{snippet}"
);
};
assert!(
(1500..=3000).contains(&ms),
"pre-send-keys boot delay must be in [1500ms, 3000ms]; got {ms}ms"
);
}
#[test]
fn build_task_prompt_is_deterministic_and_io_free() {
let entry = make_spec_entry("governance-config", "## body\n\nmore body");
let a = build_task_prompt(Some(&entry));
let b = build_task_prompt(Some(&entry));
assert_eq!(a.as_bytes(), b.as_bytes(), "must be deterministic");
let src = include_str!("main.rs");
let needle = "pub(crate) fn build_task_prompt";
let start = src
.find(needle)
.unwrap_or_else(|| panic!("build_task_prompt signature not found in main.rs"));
let body_start = src[start..].find('{').map_or_else(
|| panic!("opening brace not found after signature"),
|o| start + o,
);
let mut depth: i32 = 0;
let mut end = body_start;
for (i, ch) in src[body_start..].char_indices() {
match ch {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
end = body_start + i + 1;
break;
}
}
_ => {}
}
}
assert!(end > body_start, "did not find closing brace");
let body = &src[body_start..end];
for needle in [
"std::fs::",
"File::open",
"File::create",
"Command::new",
"tokio::fs::",
] {
assert!(
!body.contains(needle),
"build_task_prompt body must not contain `{needle}`; body:\n{body}"
);
}
}
struct StubPrompt {
answer: bool,
called: bool,
}
impl SupervisorPrompt for StubPrompt {
fn ask(&mut self) -> Result<bool, PawError> {
self.called = true;
Ok(self.answer)
}
}
fn cfg_with_supervisor(enabled: bool) -> PawConfig {
PawConfig {
supervisor: Some(SupervisorConfig {
enabled,
..Default::default()
}),
..Default::default()
}
}
#[test]
fn resolve_flag_wins_over_disabled_config() {
let cfg = cfg_with_supervisor(false);
let mut prompt = StubPrompt {
answer: false,
called: false,
};
let result = resolve_supervisor_mode(false, true, false, &cfg, &mut prompt).unwrap();
assert!(result);
assert!(!prompt.called);
}
#[test]
fn resolve_config_enabled_skips_prompt() {
let cfg = cfg_with_supervisor(true);
let mut prompt = StubPrompt {
answer: false,
called: false,
};
let result = resolve_supervisor_mode(false, false, false, &cfg, &mut prompt).unwrap();
assert!(result);
assert!(!prompt.called);
}
#[test]
fn resolve_config_disabled_skips_prompt() {
let cfg = cfg_with_supervisor(false);
let mut prompt = StubPrompt {
answer: true,
called: false,
};
let result = resolve_supervisor_mode(false, false, false, &cfg, &mut prompt).unwrap();
assert!(!result);
assert!(!prompt.called);
}
#[test]
fn resolve_dry_run_no_section_skips_prompt() {
let cfg = PawConfig::default();
let mut prompt = StubPrompt {
answer: true,
called: false,
};
let result = resolve_supervisor_mode(false, false, true, &cfg, &mut prompt).unwrap();
assert!(!result);
assert!(!prompt.called);
}
#[test]
fn resolve_no_section_prompts_and_returns_answer() {
let cfg = PawConfig::default();
let mut prompt = StubPrompt {
answer: true,
called: false,
};
let result = resolve_supervisor_mode(false, false, false, &cfg, &mut prompt).unwrap();
assert!(result);
assert!(prompt.called);
}
#[test]
fn resolve_dry_run_plus_flag_still_enables() {
let cfg = PawConfig::default();
let mut prompt = StubPrompt {
answer: false,
called: false,
};
let result = resolve_supervisor_mode(false, true, true, &cfg, &mut prompt).unwrap();
assert!(result);
}
#[test]
fn resolve_no_supervisor_overrides_config_enabled() {
let cfg = cfg_with_supervisor(true);
let mut prompt = StubPrompt {
answer: true,
called: false,
};
let result = resolve_supervisor_mode(true, false, false, &cfg, &mut prompt).unwrap();
assert!(
!result,
"--no-supervisor must override config enabled = true"
);
assert!(!prompt.called);
}
#[test]
fn resolve_no_supervisor_with_config_disabled_is_idempotent() {
let cfg = cfg_with_supervisor(false);
let mut prompt = StubPrompt {
answer: true,
called: false,
};
let result = resolve_supervisor_mode(true, false, false, &cfg, &mut prompt).unwrap();
assert!(!result);
assert!(!prompt.called);
}
#[test]
fn resolve_no_supervisor_with_no_section_skips_prompt() {
let cfg = PawConfig::default();
let mut prompt = StubPrompt {
answer: true,
called: false,
};
let result = resolve_supervisor_mode(true, false, false, &cfg, &mut prompt).unwrap();
assert!(!result);
assert!(
!prompt.called,
"--no-supervisor must short-circuit before the prompt"
);
}
#[test]
fn resolve_no_supervisor_with_dry_run_disables() {
let cfg = cfg_with_supervisor(true);
let mut prompt = StubPrompt {
answer: true,
called: false,
};
let result = resolve_supervisor_mode(true, false, true, &cfg, &mut prompt).unwrap();
assert!(!result);
assert!(!prompt.called);
}
struct PurgeSandbox {
_sandbox: TempDir,
repo: PathBuf,
sessions_dir: PathBuf,
session: Session,
}
fn git(dir: &Path, args: &[&str]) {
let out = StdCommand::new("git")
.current_dir(dir)
.args(args)
.output()
.expect("git spawn");
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
fn setup_purge_sandbox(with_unmerged_commit: bool) -> PurgeSandbox {
let sandbox = TempDir::new().expect("tempdir");
let repo = sandbox.path().join("repo");
std::fs::create_dir(&repo).expect("mkdir repo");
let sessions_dir = sandbox.path().join("sessions");
git(&repo, &["init", "-q", "-b", "main"]);
git(&repo, &["config", "user.email", "test@test.com"]);
git(&repo, &["config", "user.name", "Test"]);
std::fs::write(repo.join("README.md"), "# test").unwrap();
git(&repo, &["add", "."]);
git(&repo, &["commit", "-q", "-m", "initial"]);
let wt = git::create_worktree(&repo, "feature/test", false).expect("create worktree");
if with_unmerged_commit {
std::fs::write(wt.path.join("new.txt"), "hello").unwrap();
git(&wt.path, &["add", "."]);
git(&wt.path, &["commit", "-q", "-m", "feature work"]);
}
let session = Session {
session_name: "paw-repo".to_string(),
repo_path: repo.clone(),
project_name: "repo".to_string(),
created_at: UNIX_EPOCH,
status: SessionStatus::Stopped,
worktrees: vec![WorktreeEntry {
branch: "feature/test".to_string(),
worktree_path: wt.path.clone(),
cli: "claude".to_string(),
branch_created: wt.branch_created,
}],
broker_port: None,
broker_bind: None,
broker_log_path: None,
mode: SessionMode::Bare,
dashboard_pane: None,
};
session::save_session_in(&session, &sessions_dir).expect("save session");
PurgeSandbox {
_sandbox: sandbox,
repo,
sessions_dir,
session,
}
}
#[test]
#[serial]
fn purge_no_unmerged_commits_runs_without_warning() {
let sb = setup_purge_sandbox(false);
let worktree_path = sb.session.worktrees[0].worktree_path.clone();
let mut confirm_calls = 0;
let mut confirm = |_: &str| -> Result<bool, PawError> {
confirm_calls += 1;
Ok(true)
};
let mut stderr = Vec::<u8>::new();
let outcome = purge_with_prompt(
&sb.repo,
&sb.sessions_dir,
&sb.session,
false,
&mut confirm,
&mut |_: &str| Ok(()),
&mut stderr,
)
.unwrap();
assert_eq!(outcome, PurgeOutcome::Purged);
assert_eq!(confirm_calls, 1);
let err_text = String::from_utf8(stderr).unwrap();
assert!(
!err_text.contains("unmerged"),
"stderr should not mention unmerged: {err_text:?}"
);
assert!(!worktree_path.exists(), "worktree should be removed");
assert!(
!sb.sessions_dir.join("paw-repo.json").exists(),
"session file should be removed"
);
}
#[test]
#[serial]
fn purge_with_unmerged_commits_emits_warning_to_stderr() {
let sb = setup_purge_sandbox(true);
let mut confirm = |_: &str| -> Result<bool, PawError> { Ok(true) };
let mut stderr = Vec::<u8>::new();
let outcome = purge_with_prompt(
&sb.repo,
&sb.sessions_dir,
&sb.session,
false,
&mut confirm,
&mut |_: &str| Ok(()),
&mut stderr,
)
.unwrap();
assert_eq!(outcome, PurgeOutcome::Purged);
let err_text = String::from_utf8(stderr).unwrap();
assert!(
err_text.contains("Warning:") && err_text.contains("unmerged commits"),
"stderr should contain unmerged warning: {err_text:?}"
);
assert!(
err_text.contains("feature/test"),
"stderr should name the branch: {err_text:?}"
);
assert!(
err_text.contains("irreversible"),
"stderr should warn about data loss: {err_text:?}"
);
}
#[test]
#[serial]
fn purge_force_skips_confirm_but_still_warns() {
let sb = setup_purge_sandbox(true);
let mut confirm_calls = 0;
let mut confirm = |_: &str| -> Result<bool, PawError> {
confirm_calls += 1;
Ok(false)
};
let mut stderr = Vec::<u8>::new();
let outcome = purge_with_prompt(
&sb.repo,
&sb.sessions_dir,
&sb.session,
true, &mut confirm,
&mut |_: &str| Ok(()),
&mut stderr,
)
.unwrap();
assert_eq!(outcome, PurgeOutcome::Purged);
assert_eq!(confirm_calls, 0, "force should not invoke confirm");
let err_text = String::from_utf8(stderr).unwrap();
assert!(
err_text.contains("Warning:") && err_text.contains("unmerged commits"),
"force mode should still warn: {err_text:?}"
);
}
#[test]
#[serial]
fn purge_cancelled_leaves_worktree_in_place() {
let sb = setup_purge_sandbox(true);
let worktree_path = sb.session.worktrees[0].worktree_path.clone();
let mut confirm = |_: &str| -> Result<bool, PawError> { Ok(false) };
let mut tmux_calls = 0;
let mut kill_tmux = |_: &str| -> Result<(), PawError> {
tmux_calls += 1;
Ok(())
};
let mut stderr = Vec::<u8>::new();
let outcome = purge_with_prompt(
&sb.repo,
&sb.sessions_dir,
&sb.session,
false,
&mut confirm,
&mut kill_tmux,
&mut stderr,
)
.unwrap();
assert_eq!(outcome, PurgeOutcome::Cancelled);
assert_eq!(tmux_calls, 0, "tmux must not be killed on cancel");
assert!(worktree_path.exists(), "worktree must remain on cancel");
assert!(
sb.sessions_dir.join("paw-repo.json").exists(),
"session file must remain on cancel"
);
}
struct OrderedWrite {
events: std::rc::Rc<std::cell::RefCell<Vec<String>>>,
}
impl std::io::Write for OrderedWrite {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.events
.borrow_mut()
.push(format!("write({})", buf.len()));
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
self.events.borrow_mut().push("flush".to_string());
Ok(())
}
}
#[test]
#[serial]
fn purge_emits_per_worktree_progress_messages() {
let sb = setup_purge_sandbox(false);
let worktree_path_display = sb.session.worktrees[0].worktree_path.display().to_string();
let mut confirm = |_: &str| -> Result<bool, PawError> { Ok(true) };
let mut stderr = Vec::<u8>::new();
let outcome = purge_with_prompt(
&sb.repo,
&sb.sessions_dir,
&sb.session,
true, &mut confirm,
&mut |_: &str| Ok(()),
&mut stderr,
)
.unwrap();
assert_eq!(outcome, PurgeOutcome::Purged);
let err_text = String::from_utf8(stderr).unwrap();
assert!(
err_text.contains(&format!("Removing worktree {worktree_path_display}")),
"stderr should announce each worktree removal by path; got:\n{err_text}"
);
assert!(
err_text.contains("...done ("),
"stderr should emit a `...done (Xs)` marker per worktree once removal completes; got:\n{err_text}"
);
}
#[test]
#[serial]
fn purge_with_unmerged_commits_flushes_stderr_before_confirm() {
let sb = setup_purge_sandbox(true);
let events: std::rc::Rc<std::cell::RefCell<Vec<String>>> =
std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
let events_for_confirm = std::rc::Rc::clone(&events);
let mut confirm = |_: &str| -> Result<bool, PawError> {
events_for_confirm.borrow_mut().push("confirm".to_string());
Ok(true)
};
let mut stderr = OrderedWrite {
events: std::rc::Rc::clone(&events),
};
let outcome = purge_with_prompt(
&sb.repo,
&sb.sessions_dir,
&sb.session,
false,
&mut confirm,
&mut |_: &str| Ok(()),
&mut stderr,
)
.unwrap();
assert_eq!(outcome, PurgeOutcome::Purged);
let recorded = events.borrow().clone();
let confirm_idx = recorded
.iter()
.position(|s| s == "confirm")
.expect("confirm must have been called");
let flush_before_confirm_idx = recorded[..confirm_idx]
.iter()
.rposition(|s| s == "flush")
.expect("stderr.flush() must have been called before the prompt fired");
let has_write_before_flush = recorded[..flush_before_confirm_idx]
.iter()
.any(|s| s.starts_with("write("));
assert!(
has_write_before_flush,
"at least one stderr write must precede the pre-confirm flush; events: {recorded:?}"
);
}
}
#[cfg(test)]
mod supervisor_self_register_tests {
use std::sync::Arc;
use git_paw::broker;
use git_paw::broker::delivery;
use git_paw::broker::messages::BrokerMessage;
use super::build_status_message;
#[test]
fn supervisor_boot_status_message_has_spec_fields() {
let msg = build_status_message(
"supervisor",
"working",
Some("Supervisor booting".to_string()),
None,
);
match msg {
BrokerMessage::Status { agent_id, payload } => {
assert_eq!(agent_id, "supervisor");
assert_eq!(payload.status, "working");
assert_eq!(payload.message.as_deref(), Some("Supervisor booting"));
assert!(payload.modified_files.is_empty());
}
_ => panic!("expected BrokerMessage::Status"),
}
}
#[test]
fn publish_message_with_supervisor_boot_registers_supervisor() {
let state = Arc::new(broker::BrokerState::new(None));
let msg = build_status_message(
"supervisor",
"working",
Some("Supervisor booting".to_string()),
None,
);
delivery::publish_message(&state, &msg);
let inner = state.read();
let record = inner
.agents
.get("supervisor")
.expect("supervisor agent record exists");
assert_eq!(record.status, "working");
}
#[test]
fn supervisor_appears_in_agent_status_snapshot_after_boot() {
let state = Arc::new(broker::BrokerState::new(None));
let msg = build_status_message(
"supervisor",
"working",
Some("Supervisor booting".to_string()),
None,
);
delivery::publish_message(&state, &msg);
let snapshot = delivery::agent_status_snapshot(&state);
let entry = snapshot
.iter()
.find(|e| e.agent_id == "supervisor")
.expect("supervisor row appears in snapshot");
assert_eq!(entry.status, "working");
}
}
#[cfg(test)]
mod supervisor_question_tests {
use std::sync::Arc;
use std::sync::atomic::{AtomicU16, Ordering};
use git_paw::broker::messages::BrokerMessage;
use git_paw::broker::{self, BrokerState, delivery};
use git_paw::config::BrokerConfig;
use super::publish_supervisor_question;
static PORT_COUNTER: AtomicU16 = AtomicU16::new(0);
fn spawn_broker() -> (broker::BrokerHandle, String) {
#[allow(clippy::cast_possible_truncation)]
let base = 30_000 + (std::process::id() as u16 % 5000);
let offset = PORT_COUNTER.fetch_add(1, Ordering::SeqCst);
let mut port = base + offset;
let mut attempts = 0;
loop {
let cfg = BrokerConfig {
enabled: true,
port,
bind: "127.0.0.1".to_string(),
};
match broker::start_broker(&cfg, BrokerState::new(None), Vec::new()) {
Ok(handle) => {
let url = cfg.url();
return (handle, url);
}
Err(_) if attempts < 10 => {
port += 100;
attempts += 1;
}
Err(e) => panic!("failed to start test broker: {e}"),
}
}
}
fn curl_available() -> bool {
which::which("curl").is_ok()
}
#[test]
fn publish_supervisor_question_routes_to_supervisor_inbox() {
if !curl_available() {
eprintln!("skipping: curl not available on PATH");
return;
}
let (handle, url) = spawn_broker();
let state: Arc<BrokerState> = Arc::clone(&handle.state);
publish_supervisor_question("Continue with this approach?", &url)
.expect("publish should succeed against a live broker");
let mut found: Option<BrokerMessage> = None;
for _ in 0..40 {
let (msgs, _) = delivery::poll_messages(&state, "supervisor", 0);
if let Some(msg) = msgs.into_iter().next() {
found = Some(msg);
break;
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
let msg = found.expect("supervisor inbox should receive the question");
match msg {
BrokerMessage::Question { agent_id, payload } => {
assert_eq!(agent_id, "supervisor");
assert_eq!(payload.question, "Continue with this approach?");
}
other => panic!("expected BrokerMessage::Question, got {other:?}"),
}
}
#[test]
fn publish_supervisor_question_preserves_quotes_in_question_text() {
if !curl_available() {
eprintln!("skipping: curl not available on PATH");
return;
}
let (handle, url) = spawn_broker();
let state: Arc<BrokerState> = Arc::clone(&handle.state);
let question = r#"Should I use "bcrypt" or argon2?"#;
publish_supervisor_question(question, &url)
.expect("publish should succeed with embedded quotes");
let mut found: Option<BrokerMessage> = None;
for _ in 0..40 {
let (msgs, _) = delivery::poll_messages(&state, "supervisor", 0);
if let Some(msg) = msgs.into_iter().next() {
found = Some(msg);
break;
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
let msg = found.expect("supervisor inbox should receive the question");
match msg {
BrokerMessage::Question { agent_id, payload } => {
assert_eq!(agent_id, "supervisor");
assert!(
payload.question.contains("bcrypt"),
"stored question must include the literal word 'bcrypt'; got: {:?}",
payload.question
);
assert!(
payload.question.contains("argon2"),
"stored question must include the literal word 'argon2'; got: {:?}",
payload.question
);
}
other => panic!("expected BrokerMessage::Question, got {other:?}"),
}
}
#[test]
fn publish_supervisor_question_returns_error_when_broker_unreachable() {
if !curl_available() {
eprintln!("skipping: curl not available on PATH");
return;
}
let result = publish_supervisor_question("anything", "http://127.0.0.1:1");
assert!(
result.is_err(),
"publishing to an unreachable broker must error"
);
}
}