feat(branch): add /merge command and background guard events

This commit is contained in:
James Peter
2025-09-16 20:07:15 +10:00
parent 8db3136452
commit eb4c2bc070
7 changed files with 468 additions and 260 deletions
+16
View File
@@ -5127,6 +5127,10 @@ async fn handle_container_exec_with_params(
.unwrap_or_else(|_| "<failed to serialize suggested argv>".to_string());
let guidance = pattern.guidance("original_script", &script, &suggested);
sess
.notify_background_event(&sub_id, format!("Command guard: {}", guidance))
.await;
return ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload { content: guidance, success: None },
@@ -5142,6 +5146,10 @@ async fn handle_container_exec_with_params(
let guidance = guidance_for_sensitive_git(kind, "original_script", &script, &suggested);
sess
.notify_background_event(&sub_id, format!("Command guard: {}", guidance.clone()))
.await;
return ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload { content: guidance, success: None },
@@ -5320,6 +5328,10 @@ async fn handle_container_exec_with_params(
&suggested,
);
sess
.notify_background_event(&sub_id, format!("Command guard: {}", guidance.clone()))
.await;
return ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload { content: guidance, success: None },
@@ -5392,6 +5404,10 @@ async fn handle_container_exec_with_params(
let guidance = guidance_for_sensitive_git(kind, "original_argv", &format!("{:?}", params.command), &suggested);
sess
.notify_background_event(&sub_id, format!("Command guard: {}", guidance.clone()))
.await;
return ResponseInputItem::FunctionCallOutput { call_id, output: FunctionCallOutputPayload { content: guidance, success: None } };
}
}
+11 -1
View File
@@ -753,6 +753,11 @@ impl App<'_> {
widget.handle_branch_command(command_args);
}
}
SlashCommand::Merge => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_merge_command();
}
}
SlashCommand::Resume => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.show_resume_picker();
@@ -1001,7 +1006,7 @@ impl App<'_> {
widget.queue_agent_note(branch_note);
if let Some(prompt) = initial_prompt {
if !prompt.is_empty() {
let preface = "[internal] When you finish this task, ask the user if they want any changes. If they are happy, offer to merge the branch back into the repository's default branch and delete the worktree. Use '/branch finalize' (or an equivalent git worktree remove + switch) rather than deleting the folder directly so the UI can switch back cleanly. Wait for explicit confirmation before merging.".to_string();
let preface = "[internal] When you finish this task, ask the user if they want any changes. If they are happy, offer to merge the branch back into the repository's default branch and delete the worktree. Use '/merge' (or an equivalent git worktree remove + switch) rather than deleting the folder directly so the UI can switch back cleanly. Wait for explicit confirmation before merging.".to_string();
widget.submit_text_message_with_preface(prompt, preface);
}
}
@@ -1094,6 +1099,11 @@ impl App<'_> {
widget.insert_str(&text);
}
}
AppEvent::SubmitTextWithPreface { visible, preface } => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.submit_text_message_with_preface(visible, preface);
}
}
AppEvent::DiffResult(text) => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.add_diff_output(text);
+3
View File
@@ -89,6 +89,9 @@ pub(crate) enum AppEvent {
/// Prefill the composer input with the given text
PrefillComposer(String),
/// Submit a message with hidden preface instructions
SubmitTextWithPreface { visible: String, preface: String },
/// Update the theme (with history event)
UpdateTheme(ThemeName),
/// Add or update a subagent command in memory (UI already persisted to config.toml)
+420 -244
View File
@@ -11,12 +11,14 @@ use std::sync::atomic::Ordering;
use ratatui::style::Modifier;
use ratatui::style::Style;
use codex_common::model_presets::{builtin_model_presets, ModelPreset};
use codex_common::model_presets::ModelPreset;
use codex_common::model_presets::builtin_model_presets;
use codex_core::ConversationManager;
use codex_core::config::Config;
use codex_core::config_types::ReasoningEffort;
use codex_core::config_types::TextVerbosity;
use codex_core::model_family::{derive_default_model_family, find_family_for_model};
use codex_core::model_family::derive_default_model_family;
use codex_core::model_family::find_family_for_model;
use codex_login::AuthManager;
use codex_login::AuthMode;
use codex_protocol::mcp_protocol::AuthMode as McpAuthMode;
@@ -460,6 +462,52 @@ enum SystemPlacement {
}
impl ChatWidget<'_> {
fn is_branch_worktree_path(path: &std::path::Path) -> bool {
let mut current = Some(path);
while let Some(cur) = current {
if let Some(name) = cur.file_name() {
if name == std::ffi::OsStr::new("branches") {
if let Some(parent) = cur.parent() {
if parent.file_name() == Some(std::ffi::OsStr::new(".code")) {
return true;
}
}
}
}
current = cur.parent();
}
false
}
async fn git_short_status(path: &std::path::Path) -> Result<String, String> {
use tokio::process::Command;
match Command::new("git")
.current_dir(path)
.args(["status", "--short"])
.output()
.await
{
Ok(out) if out.status.success() => Ok(String::from_utf8_lossy(&out.stdout).to_string()),
Ok(out) => {
let stderr_s = String::from_utf8_lossy(&out.stderr).trim().to_string();
let stdout_s = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !stderr_s.is_empty() {
Err(stderr_s)
} else if !stdout_s.is_empty() {
Err(stdout_s)
} else {
let code = out
.status
.code()
.map(|c| format!("exit status {c}"))
.unwrap_or_else(|| "terminated by signal".to_string());
Err(format!("git status failed: {}", code))
}
}
Err(err) => Err(err.to_string()),
}
}
/// Export history and ordering state so the UI can swap sessions (e.g., /branch)
/// without losing visible conversation. This drains the current widget's history.
pub(crate) fn export_history_for_session_swap(
@@ -2859,7 +2907,10 @@ impl ChatWidget<'_> {
.any(|c| c.name.eq_ignore_ascii_case(cmd_name));
// Treat built-ins via the standard path below to preserve existing ack flow,
// but allow any other saved subagent command to be executed here.
let is_builtin = matches!(cmd_name.to_ascii_lowercase().as_str(), "plan"|"solve"|"code");
let is_builtin = matches!(
cmd_name.to_ascii_lowercase().as_str(),
"plan" | "solve" | "code"
);
if has_custom && !is_builtin {
let res = codex_core::slash_commands::format_subagent_command(
cmd_name,
@@ -2870,13 +2921,23 @@ impl ChatWidget<'_> {
// Acknowledge configuration
let mode = if res.read_only { "read-only" } else { "write" };
let mut ack: Vec<ratatui::text::Line<'static>> = Vec::new();
ack.push(ratatui::text::Line::from(format!("/{} configured", res.name)));
ack.push(ratatui::text::Line::from(format!(
"/{} configured",
res.name
)));
ack.push(ratatui::text::Line::from(format!("mode: {}", mode)));
ack.push(ratatui::text::Line::from(format!(
"agents: {}",
if res.models.is_empty() { "<none>".to_string() } else { res.models.join(", ") }
if res.models.is_empty() {
"<none>".to_string()
} else {
res.models.join(", ")
}
)));
ack.push(ratatui::text::Line::from(format!(
"command: {}",
original_text.trim()
)));
ack.push(ratatui::text::Line::from(format!("command: {}", original_text.trim())));
self.history_push(crate::history_cell::PlainHistoryCell {
lines: ack,
kind: crate::history_cell::HistoryCellType::Notice,
@@ -2921,7 +2982,11 @@ impl ChatWidget<'_> {
lines.push(Line::from(format!("mode: {}", mode)));
lines.push(Line::from(format!(
"agents: {}",
if res.models.is_empty() { "<none>".to_string() } else { res.models.join(", ") }
if res.models.is_empty() {
"<none>".to_string()
} else {
res.models.join(", ")
}
)));
lines.push(Line::from(format!("command: {}", original_text.trim())));
self.history_push(crate::history_cell::PlainHistoryCell {
@@ -4287,20 +4352,35 @@ impl ChatWidget<'_> {
// Show current subagent command configuration summary
lines.push(Line::from("Subagents configuration".bold()));
if self.config.subagent_commands.is_empty() {
lines.push(Line::from(" • No subagent commands in config (using defaults)"));
lines.push(Line::from(
" • No subagent commands in config (using defaults)",
));
} else {
for cmd in &self.config.subagent_commands {
let mode = if cmd.read_only { "read-only" } else { "write" };
let agents = if cmd.agents.is_empty() { "<inherit>".to_string() } else { cmd.agents.join(", ") };
lines.push(Line::from(format!(" • {} — {} — [{}]", cmd.name, mode, agents)));
let agents = if cmd.agents.is_empty() {
"<inherit>".to_string()
} else {
cmd.agents.join(", ")
};
lines.push(Line::from(format!(
" • {} — {} — [{}]",
cmd.name, mode, agents
)));
}
}
lines.push(Line::from(""));
lines.push(Line::from("Manage with:".bold()));
lines.push(Line::from(" /agents add name=<name> read-only=<true|false> agents=claude,gemini,qwen,code"));
lines.push(Line::from(" /agents edit name=<name> [read-only=..] [agents=..] [orchestrator=..] [agent=..]"));
lines.push(Line::from(
" /agents add name=<name> read-only=<true|false> agents=claude,gemini,qwen,code",
));
lines.push(Line::from(
" /agents edit name=<name> [read-only=..] [agents=..] [orchestrator=..] [agent=..]",
));
lines.push(Line::from(" /agents delete name=<name>"));
lines.push(Line::from(" Values with spaces require quotes in the composer."));
lines.push(Line::from(
" Values with spaces require quotes in the composer.",
));
lines.push(Line::from(""));
// Platform + environment summary to aid debugging
@@ -4447,17 +4527,27 @@ impl ChatWidget<'_> {
}
#[cfg(target_os = "windows")]
{
if let Ok(p) = which::which(cmd) { p.is_file() } else { false }
if let Ok(p) = which::which(cmd) {
p.is_file()
} else {
false
}
}
#[cfg(not(target_os = "windows"))]
{
use std::os::unix::fs::PermissionsExt;
let Some(path_os) = std::env::var_os("PATH") else { return false; };
let Some(path_os) = std::env::var_os("PATH") else {
return false;
};
for dir in std::env::split_paths(&path_os) {
if dir.as_os_str().is_empty() { continue; }
if dir.as_os_str().is_empty() {
continue;
}
let candidate = dir.join(cmd);
if let Ok(meta) = std::fs::metadata(&candidate) {
if meta.is_file() && (meta.permissions().mode() & 0o111 != 0) { return true; }
if meta.is_file() && (meta.permissions().mode() & 0o111 != 0) {
return true;
}
}
}
false
@@ -4469,17 +4559,37 @@ impl ChatWidget<'_> {
let preferred = ["code", "claude", "gemini", "qwen"];
// Name -> config lookup
let mut extras: Vec<String> = Vec::new();
for a in &self.config.agents { if !preferred.iter().any(|p| a.name.eq_ignore_ascii_case(p)) { extras.push(a.name.to_ascii_lowercase()); } }
for a in &self.config.agents {
if !preferred.iter().any(|p| a.name.eq_ignore_ascii_case(p)) {
extras.push(a.name.to_ascii_lowercase());
}
}
extras.sort();
// Build ordered list of names
let mut ordered: Vec<String> = Vec::new();
for p in preferred { ordered.push(p.to_string()); }
for e in extras { if !ordered.iter().any(|n| n.eq_ignore_ascii_case(&e)) { ordered.push(e); } }
for p in preferred {
ordered.push(p.to_string());
}
for e in extras {
if !ordered.iter().any(|n| n.eq_ignore_ascii_case(&e)) {
ordered.push(e);
}
}
for name in ordered.iter() {
if let Some(cfg) = self.config.agents.iter().find(|a| a.name.eq_ignore_ascii_case(name)) {
if let Some(cfg) = self
.config
.agents
.iter()
.find(|a| a.name.eq_ignore_ascii_case(name))
{
let installed = command_exists(&cfg.command);
agent_rows.push((cfg.name.clone(), cfg.enabled, installed, cfg.command.clone()));
agent_rows.push((
cfg.name.clone(),
cfg.enabled,
installed,
cfg.command.clone(),
));
} else {
// Default command = name, enabled=true, installed based on PATH
let cmd = name.clone();
@@ -4507,7 +4617,12 @@ impl ChatWidget<'_> {
pub(crate) fn show_subagent_editor_for_name(&mut self, name: String) {
// Build available agents from enabled ones (or sensible defaults)
let available_agents: Vec<String> = if self.config.agents.is_empty() {
vec!["claude".into(), "gemini".into(), "qwen".into(), "code".into()]
vec![
"claude".into(),
"gemini".into(),
"qwen".into(),
"code".into(),
]
} else {
self.config
.agents
@@ -4523,7 +4638,12 @@ impl ChatWidget<'_> {
pub(crate) fn show_new_subagent_editor(&mut self) {
let available_agents: Vec<String> = if self.config.agents.is_empty() {
vec!["claude".into(), "gemini".into(), "qwen".into(), "code".into()]
vec![
"claude".into(),
"gemini".into(),
"qwen".into(),
"code".into(),
]
} else {
self.config
.agents
@@ -4538,14 +4658,33 @@ impl ChatWidget<'_> {
}
pub(crate) fn show_agent_editor_ui(&mut self, name: String) {
if let Some(cfg) = self.config.agents.iter().find(|a| a.name.eq_ignore_ascii_case(&name)).cloned() {
let ro = if let Some(ref v) = cfg.args_read_only { Some(v.clone()) }
else if !cfg.args.is_empty() { Some(cfg.args.clone()) }
else { let d = codex_core::agent_defaults::default_params_for(&cfg.name, true /*read_only*/); if d.is_empty() { None } else { Some(d) } };
let wr = if let Some(ref v) = cfg.args_write { Some(v.clone()) }
else if !cfg.args.is_empty() { Some(cfg.args.clone()) }
else { let d = codex_core::agent_defaults::default_params_for(&cfg.name, false /*read_only*/); if d.is_empty() { None } else { Some(d) } };
if let Some(cfg) = self
.config
.agents
.iter()
.find(|a| a.name.eq_ignore_ascii_case(&name))
.cloned()
{
let ro = if let Some(ref v) = cfg.args_read_only {
Some(v.clone())
} else if !cfg.args.is_empty() {
Some(cfg.args.clone())
} else {
let d = codex_core::agent_defaults::default_params_for(
&cfg.name, true, /*read_only*/
);
if d.is_empty() { None } else { Some(d) }
};
let wr = if let Some(ref v) = cfg.args_write {
Some(v.clone())
} else if !cfg.args.is_empty() {
Some(cfg.args.clone())
} else {
let d = codex_core::agent_defaults::default_params_for(
&cfg.name, false, /*read_only*/
);
if d.is_empty() { None } else { Some(d) }
};
self.bottom_pane.show_agent_editor(
cfg.name.clone(),
cfg.enabled,
@@ -4558,7 +4697,8 @@ impl ChatWidget<'_> {
// Fallback: synthesize defaults
let cmd = name.clone();
let ro = codex_core::agent_defaults::default_params_for(&name, true /*read_only*/);
let wr = codex_core::agent_defaults::default_params_for(&name, false /*read_only*/);
let wr =
codex_core::agent_defaults::default_params_for(&name, false /*read_only*/);
self.bottom_pane.show_agent_editor(
name,
true,
@@ -4570,7 +4710,10 @@ impl ChatWidget<'_> {
}
}
pub(crate) fn apply_subagent_update(&mut self, cmd: codex_core::config_types::SubagentCommandConfig) {
pub(crate) fn apply_subagent_update(
&mut self,
cmd: codex_core::config_types::SubagentCommandConfig,
) {
if let Some(slot) = self
.config
.subagent_commands
@@ -4597,7 +4740,12 @@ impl ChatWidget<'_> {
args_wr: Option<Vec<String>>,
instr: Option<String>,
) {
if let Some(slot) = self.config.agents.iter_mut().find(|a| a.name.eq_ignore_ascii_case(name)) {
if let Some(slot) = self
.config
.agents
.iter_mut()
.find(|a| a.name.eq_ignore_ascii_case(name))
{
slot.enabled = enabled;
slot.args_read_only = args_ro.clone();
slot.args_write = args_wr.clone();
@@ -4622,8 +4770,6 @@ impl ChatWidget<'_> {
}
}
pub(crate) fn show_diffs_popup(&mut self) {
use crate::diff_render::create_diff_details_only;
// Build a latest-first unique file list
@@ -4958,7 +5104,9 @@ impl ChatWidget<'_> {
let presets = self.available_model_presets();
if presets.is_empty() {
let message = "No model presets are available. Update your configuration to define models.".to_string();
let message =
"No model presets are available. Update your configuration to define models."
.to_string();
self.history_push(history_cell::new_error_event(message));
return;
}
@@ -4985,11 +5133,7 @@ impl ChatWidget<'_> {
);
}
pub(crate) fn apply_model_selection(
&mut self,
model: String,
effort: Option<ReasoningEffort>,
) {
pub(crate) fn apply_model_selection(&mut self, model: String, effort: Option<ReasoningEffort>) {
let trimmed = model.trim();
if trimmed.is_empty() {
return;
@@ -5032,10 +5176,7 @@ impl ChatWidget<'_> {
let placement = self.ui_placement_for_now();
self.push_system_cell(
history_cell::new_model_output(
&self.config.model,
self.config.model_reasoning_effort,
),
history_cell::new_model_output(&self.config.model, self.config.model_reasoning_effort),
placement,
Some("ui:model".to_string()),
None,
@@ -5071,7 +5212,9 @@ impl ChatWidget<'_> {
} else {
let presets = self.available_model_presets();
if presets.is_empty() {
let message = "No model presets are available. Update your configuration to define models.".to_string();
let message =
"No model presets are available. Update your configuration to define models."
.to_string();
self.history_push(history_cell::new_error_event(message));
return;
}
@@ -8514,7 +8657,10 @@ impl ChatWidget<'_> {
if !prompt.trim().is_empty() {
ordered.push(InputItem::Text { text: prompt });
}
let msg = UserMessage { display_text: display, ordered_items: ordered };
let msg = UserMessage {
display_text: display,
ordered_items: ordered,
};
self.submit_user_message(msg);
}
@@ -9065,11 +9211,15 @@ impl ChatWidget<'_> {
/// optionally copies current uncommitted changes, then switches the session cwd
/// into the worktree. If `task` is non-empty, submits it immediately.
pub(crate) fn handle_branch_command(&mut self, args: String) {
let args_trim = args.trim().to_string();
if matches!(args_trim.as_str(), "finalize" | "merge") {
self.handle_branch_finalize();
if Self::is_branch_worktree_path(&self.config.cwd) {
self.history_push(crate::history_cell::new_error_event(
"`/branch` — already inside a branch worktree; switch to the repo root before creating another branch."
.to_string(),
));
self.request_redraw();
return;
}
let args_trim = args.trim().to_string();
let cwd = self.config.cwd.clone();
let tx = self.app_event_tx.clone();
// Add a quick notice into history, include task preview if provided
@@ -9242,69 +9392,159 @@ impl ChatWidget<'_> {
});
}
/// Handle `/branch finalize` to merge the branch back into the default branch
/// and remove the worktree. Must be invoked from inside the worktree.
pub(crate) fn handle_branch_finalize(&mut self) {
/// Handle `/merge` to merge the current worktree branch back into the
/// default branch. Hands off to the agent when the repository state is
/// non-trivial.
pub(crate) fn handle_merge_command(&mut self) {
if !Self::is_branch_worktree_path(&self.config.cwd) {
self.history_push(crate::history_cell::new_error_event(
"`/merge` — run this command from inside a branch worktree created with '/branch'."
.to_string(),
));
self.request_redraw();
return;
}
let tx = self.app_event_tx.clone();
let work_cwd = self.config.cwd.clone();
// Inform in history
self.history_push(crate::history_cell::new_background_event(
"Finalizing branch: committing, merging to default, and cleaning up...".to_string(),
"Evaluating repository state before merging current branch...".to_string(),
));
self.request_redraw();
tokio::spawn(async move {
use tokio::process::Command;
// Resolve the MAIN repository root (not the linked worktree root).
// Using git_common_dir avoids attempting to checkout the default branch
// inside the temporary worktree, which can fail with
// "already checked out in worktree" errors.
fn send_background(tx: &AppEventSender, message: String) {
use codex_core::protocol::BackgroundEventEvent;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
let _ = tx.send(AppEvent::CodexEvent(Event {
id: uuid::Uuid::new_v4().to_string(),
event_seq: 0,
msg: EventMsg::BackgroundEvent(BackgroundEventEvent { message }),
order: None,
}));
}
let git_root = match codex_core::git_info::resolve_root_git_project_for_trust(&work_cwd)
{
Some(p) => p,
None => {
use codex_core::protocol::BackgroundEventEvent;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
let _ = tx.send(AppEvent::CodexEvent(Event {
id: uuid::Uuid::new_v4().to_string(),
event_seq: 0,
msg: EventMsg::BackgroundEvent(BackgroundEventEvent {
message: "`/branch finalize` — not a git repo".to_string(),
}),
order: None,
}));
return;
}
};
// Determine branch name from HEAD
let head = Command::new("git")
.current_dir(&work_cwd)
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.output()
.await;
let branch_name = match head {
Ok(out) if out.status.success() => {
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
_ => {
use codex_core::protocol::BackgroundEventEvent;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
let _ = tx.send(AppEvent::CodexEvent(Event {
id: uuid::Uuid::new_v4().to_string(),
event_seq: 0,
msg: EventMsg::BackgroundEvent(BackgroundEventEvent {
message: "`/branch finalize` — failed to detect branch name"
.to_string(),
}),
order: None,
}));
send_background(&tx, "`/merge` — not a git repo".to_string());
return;
}
};
// Commit any pending changes in the worktree
let branch_name = match Command::new("git")
.current_dir(&work_cwd)
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.output()
.await
{
Ok(out) if out.status.success() => {
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
_ => {
send_background(&tx, "`/merge` — failed to detect branch name".to_string());
return;
}
};
let worktree_status_raw = ChatWidget::git_short_status(&work_cwd).await;
let worktree_status_for_agent = match &worktree_status_raw {
Ok(s) if s.trim().is_empty() => "clean".to_string(),
Ok(s) => s.clone(),
Err(err) => format!("status unavailable: {}", err),
};
let worktree_dirty = matches!(&worktree_status_raw, Ok(s) if !s.trim().is_empty());
let repo_status_raw = ChatWidget::git_short_status(&git_root).await;
let repo_status_for_agent = match &repo_status_raw {
Ok(s) if s.trim().is_empty() => "clean".to_string(),
Ok(s) => s.clone(),
Err(err) => format!("status unavailable: {}", err),
};
let repo_dirty = matches!(&repo_status_raw, Ok(s) if !s.trim().is_empty());
let default_branch_opt = codex_core::git_worktree::detect_default_branch(&git_root).await;
let default_branch_hint = default_branch_opt
.clone()
.unwrap_or_else(|| "<detect default branch>".to_string());
let mut handoff_reasons: Vec<String> = Vec::new();
if let Err(err) = &worktree_status_raw {
handoff_reasons.push(format!("unable to read worktree status: {}", err));
}
if worktree_dirty {
handoff_reasons.push("worktree has uncommitted changes".to_string());
}
if let Err(err) = &repo_status_raw {
handoff_reasons.push(format!("unable to read repo status: {}", err));
}
if repo_dirty {
handoff_reasons.push("default branch checkout has uncommitted changes".to_string());
}
if default_branch_opt.is_none() {
handoff_reasons.push("could not determine default branch".to_string());
}
let branch_label = format!("{}", branch_name);
let root_display = git_root.display().to_string();
let worktree_display = work_cwd.display().to_string();
let send_agent_handoff = |mut reasons: Vec<String>,
extra_note: Option<String>,
worktree_status: String,
repo_status: String| {
if reasons.is_empty() {
reasons.push("manual follow-up requested".to_string());
}
let reason_text = reasons.join(", ");
send_background(&tx, format!("`/merge` — handing off to agent ({})", reason_text));
let _ = tx.send(AppEvent::PrepareAgents);
let mut preface = format!(
"[developer] Non-trivial git state detected while finalizing the branch. Reasons: {}.\n\nRepository context:\n- Repo root: {}\n- Worktree: {}\n- Branch to merge: {}\n- Default branch target: {}\n\nCurrent git status:\nWorktree status:\n{}\n\nRepo root status:\n{}\n\nRequired actions:\n1. cd {}\n - Inspect status. Stage and commit any pending changes (`git add -A` + `git commit -m \"merge {} via /merge\"`) before proceeding.\n2. git fetch origin {}\n3. Merge the default branch into the worktree branch (`git merge origin/{}`) and resolve conflicts.\n4. cd {}\n - Ensure the local {} branch exists (create tracking branch if needed). If checkout complains about local changes, stash safely, then checkout and pop/apply before finishing.\n5. Merge {} into {} from {} (`git merge --no-ff {}`) and resolve conflicts.\n6. Remove the worktree (`git worktree remove {} --force`) and delete the branch (`git branch -D {}`).\n7. End inside {} with a clean working tree and no leftover stashes. Pop/apply anything you created.\n\nReport back with a concise summary of the steps or explain any blockers.",
reason_text,
root_display,
worktree_display,
branch_label,
default_branch_hint,
worktree_status,
repo_status,
worktree_display,
branch_label,
default_branch_hint,
default_branch_hint,
root_display,
default_branch_hint,
branch_label,
default_branch_hint,
root_display,
branch_label,
worktree_display,
branch_label,
root_display
);
if let Some(note) = extra_note {
preface.push_str("\n\nAdditional notes:\n");
preface.push_str(&note);
}
let visible = format!("Finalize branch '{}' via /merge (agent handoff)", branch_label);
let _ = tx.send(AppEvent::SubmitTextWithPreface { visible, preface });
};
if !handoff_reasons.is_empty() {
send_agent_handoff(
handoff_reasons,
None,
worktree_status_for_agent.clone(),
repo_status_for_agent.clone(),
);
return;
}
let default_branch = default_branch_opt.expect("default branch must exist when clean");
let _ = Command::new("git")
.current_dir(&work_cwd)
.args(["add", "-A"])
@@ -9312,12 +9552,11 @@ impl ChatWidget<'_> {
.await;
let commit_out = Command::new("git")
.current_dir(&work_cwd)
.args(["commit", "-m", &format!("merge {branch_name} via /branch")])
.args(["commit", "-m", &format!("merge {branch_label} via /merge")])
.output()
.await;
if let Ok(o) = &commit_out {
if !o.status.success() {
// Git prints "nothing to commit, working tree clean" on stdout and exits 1.
let stderr_s = String::from_utf8_lossy(&o.stderr);
let stdout_s = String::from_utf8_lossy(&o.stdout);
let benign = stdout_s.contains("nothing to commit")
@@ -9325,52 +9564,29 @@ impl ChatWidget<'_> {
|| stderr_s.contains("nothing to commit")
|| stderr_s.contains("working tree clean");
if !benign {
use codex_core::protocol::BackgroundEventEvent;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
let detail = if !stderr_s.trim().is_empty() {
stderr_s.trim().to_string()
} else {
stdout_s.trim().to_string()
};
let _ = tx.send(AppEvent::CodexEvent(Event {
id: uuid::Uuid::new_v4().to_string(),
event_seq: 0,
msg: EventMsg::BackgroundEvent(BackgroundEventEvent {
message: format!("`/branch finalize` — commit failed: {}", detail),
}),
order: None,
}));
send_background(
&tx,
format!(
"`/merge` — commit failed before merge: {}",
if !stderr_s.trim().is_empty() {
stderr_s.trim().to_string()
} else {
stdout_s.trim().to_string()
}
),
);
return;
}
}
}
// 1) Try to merge the repository default branch into the worktree branch first
// so conflicts can be resolved locally.
let default_branch = match codex_core::git_worktree::detect_default_branch(&git_root)
.await
{
Some(b) => b,
None => {
use codex_core::protocol::BackgroundEventEvent;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
let _ = tx.send(AppEvent::CodexEvent(Event { id: uuid::Uuid::new_v4().to_string(), event_seq: 0, msg: EventMsg::BackgroundEvent(BackgroundEventEvent { message: "`/branch finalize` — failed to determine default branch (tried origin/HEAD, main, master)".to_string() }), order: None }));
return;
}
};
// Fetch latest default branch; ignore errors
let _ = Command::new("git")
.current_dir(&git_root)
.args(["fetch", "origin", &default_branch])
.output()
.await;
// Prefer remote tracking ref when available
let remote_ref = format!("origin/{}", default_branch);
// Attempt a fast-forward merge of default into the worktree branch
let ff_only = Command::new("git")
.current_dir(&work_cwd)
.args(["merge", "--ff-only", &remote_ref])
@@ -9378,7 +9594,6 @@ impl ChatWidget<'_> {
.await;
if !matches!(ff_only, Ok(ref o) if o.status.success()) {
// Try a non-ff merge without committing to see if conflicts arise
let try_merge = Command::new("git")
.current_dir(&work_cwd)
.args(["merge", "--no-ff", "--no-commit", &remote_ref])
@@ -9386,64 +9601,36 @@ impl ChatWidget<'_> {
.await;
if let Ok(out) = try_merge {
if out.status.success() {
// No conflicts; create a merge commit and continue
let _ = Command::new("git")
.current_dir(&work_cwd)
.args([
"commit",
"-m",
&format!(
"merge {} into {} before finalize",
default_branch, branch_name
),
&format!("merge {} into {} before merge", default_branch, branch_label),
])
.output()
.await;
} else {
// Likely conflicts. Detect unmerged paths; if any, surface and ask the agent to resolve.
let status = Command::new("git")
.current_dir(&work_cwd)
.args(["status", "--porcelain"])
.output()
.await;
let has_conflicts = status
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.lines().any(|l| l.starts_with('U') || l.contains("UU ")))
.unwrap_or(false);
if has_conflicts {
use codex_core::protocol::BackgroundEventEvent;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
let _ = tx.send(AppEvent::CodexEvent(Event {
id: uuid::Uuid::new_v4().to_string(),
event_seq: 0,
msg: EventMsg::BackgroundEvent(BackgroundEventEvent {
message: format!(
"`/branch finalize` — conflicts while merging '{}' into '{}'. Starting agent to help resolve; re-run '/branch finalize' after conflicts are resolved.",
default_branch, branch_name
),
}),
order: None,
}));
// Prefill composer with a resolution task; keep index in conflicted state
let _ = tx.send(AppEvent::SwitchCwd(
work_cwd.clone(),
Some(format!(
"Resolve the current git merge conflicts by merging '{}' into '{}', preferring the branch's intent where appropriate. Explain changes briefly, then stage and commit with an informative message.",
default_branch, branch_name
)),
));
return;
}
let updated_worktree_status = ChatWidget::git_short_status(&work_cwd)
.await
.map(|s| if s.trim().is_empty() { "clean".to_string() } else { s })
.unwrap_or_else(|err| format!("status unavailable: {}", err));
send_agent_handoff(
vec![format!(
"merge conflicts while merging '{}' into '{}'",
default_branch, branch_label
)],
Some(
"The worktree currently has an in-progress merge that needs to be resolved. Please complete it before retrying the final merge.".to_string(),
),
updated_worktree_status,
repo_status_for_agent.clone(),
);
return;
}
}
}
// 2) Merge the worktree branch into the default branch at the repo root
// Merge branch into default from the main repo root
// Skip checkout if already on the default branch
let on_default = match Command::new("git")
.current_dir(&git_root)
.args(["rev-parse", "--abbrev-ref", "HEAD"])
@@ -9457,7 +9644,6 @@ impl ChatWidget<'_> {
};
if !on_default {
// Ensure local default branch exists; if missing, try to fetch and create a tracking branch
let has_local = match Command::new("git")
.current_dir(&git_root)
.args([
@@ -9490,7 +9676,6 @@ impl ChatWidget<'_> {
.await;
}
// Try to checkout the default branch and surface errors
let co = Command::new("git")
.current_dir(&git_root)
.args(["checkout", &default_branch])
@@ -9507,7 +9692,13 @@ impl ChatWidget<'_> {
})
.unwrap_or_else(|| (String::new(), String::new()));
// If the branch is checked out in another worktree, hint its path
let mut note = String::new();
if !stderr_s.is_empty() {
note = stderr_s;
} else if !stdout_s.is_empty() {
note = stdout_s;
}
let mut hint: Option<String> = None;
if let Ok(wt) = Command::new("git")
.current_dir(&git_root)
@@ -9529,48 +9720,43 @@ impl ChatWidget<'_> {
cur_branch = Some(rest.trim().to_string());
}
if let (Some(p), Some(b)) = (&cur_path, &cur_branch) {
if b == &format!("refs/heads/{}", default_branch) {
// Avoid hinting the main repo root
if std::path::Path::new(p) != git_root.as_path() {
hint = Some(p.clone());
break;
}
if b == &format!("refs/heads/{}", default_branch)
&& std::path::Path::new(p) != git_root.as_path()
{
hint = Some(p.clone());
break;
}
}
}
}
}
use codex_core::protocol::BackgroundEventEvent;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
let mut msg = format!(
"`/branch finalize` — failed to checkout default branch '{}'",
default_branch
if let Some(h) = hint {
if note.is_empty() {
note = format!("default branch checked out in worktree: {}", h);
} else {
note = format!("{} (checked out in worktree: {})", note, h);
}
}
let updated_repo_status = ChatWidget::git_short_status(&git_root)
.await
.map(|s| if s.trim().is_empty() { "clean".to_string() } else { s })
.unwrap_or_else(|err| format!("status unavailable: {}", err));
send_agent_handoff(
vec![format!("failed to checkout '{}' in repo root", default_branch)],
if note.is_empty() { None } else { Some(note) },
worktree_status_for_agent.clone(),
updated_repo_status,
);
let detail = if !stderr_s.is_empty() {
stderr_s
} else {
stdout_s
};
if !detail.is_empty() {
msg = format!("{}: {}", msg, detail);
}
if let Some(p) = hint {
msg = format!("{} (checked out in worktree: {})", msg, p);
}
let _ = tx.send(AppEvent::CodexEvent(Event {
id: uuid::Uuid::new_v4().to_string(),
event_seq: 0,
msg: EventMsg::BackgroundEvent(BackgroundEventEvent { message: msg }),
order: None,
}));
return;
}
}
let merge = Command::new("git")
.current_dir(&git_root)
.args(["merge", "--no-ff", &branch_name])
.args(["merge", "--no-ff", &branch_label])
.output()
.await;
if !matches!(merge, Ok(ref o) if o.status.success()) {
@@ -9578,21 +9764,24 @@ impl ChatWidget<'_> {
.ok()
.and_then(|o| String::from_utf8(o.stderr).ok())
.unwrap_or_else(|| "unknown error".to_string());
use codex_core::protocol::BackgroundEventEvent;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
let _ = tx.send(AppEvent::CodexEvent(Event {
id: uuid::Uuid::new_v4().to_string(),
event_seq: 0,
msg: EventMsg::BackgroundEvent(BackgroundEventEvent {
message: format!("`/branch finalize` — merge failed: {}", err.trim()),
}),
order: None,
}));
let updated_repo_status = ChatWidget::git_short_status(&git_root)
.await
.map(|s| if s.trim().is_empty() { "clean".to_string() } else { s })
.unwrap_or_else(|e| format!("status unavailable: {}", e));
send_agent_handoff(
vec![format!(
"merge of '{}' into '{}' failed: {}",
branch_label,
default_branch,
err.trim()
)],
None,
worktree_status_for_agent.clone(),
updated_repo_status,
);
return;
}
// After a successful merge, remove the worktree and delete the branch
let _ = Command::new("git")
.current_dir(&git_root)
.args(["worktree", "remove", work_cwd.to_str().unwrap(), "--force"])
@@ -9600,30 +9789,17 @@ impl ChatWidget<'_> {
.await;
let _ = Command::new("git")
.current_dir(&git_root)
.args(["branch", "-D", &branch_name])
.args(["branch", "-D", &branch_label])
.output()
.await;
// Inform user and switch back to git root
let msg = format!(
"Merged '{}' into '{}' and cleaned up worktree. Switching back to {}",
branch_name,
branch_label,
default_branch,
git_root.display()
);
{
use codex_core::protocol::BackgroundEventEvent;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
let _ = tx.send(AppEvent::CodexEvent(Event {
id: uuid::Uuid::new_v4().to_string(),
event_seq: 0,
msg: EventMsg::BackgroundEvent(BackgroundEventEvent {
message: msg.clone(),
}),
order: None,
}));
}
send_background(&tx, msg);
tx.send(AppEvent::SwitchCwd(git_root, None));
});
}
@@ -11254,7 +11430,7 @@ impl WidgetRef for &ChatWidget<'_> {
_ => crate::colors::primary(),
}
} else if matches!(symbol, "○" | "◔" | "◑" | "◕" | "●") {
crate::colors::primary()
crate::colors::success()
} else {
match symbol {
"›" => crate::colors::text(), // user
+9 -13
View File
@@ -5073,7 +5073,7 @@ fn popular_commands_lines() -> Vec<Line<'static>> {
Span::from(SlashCommand::Model.description())
.style(Style::default().add_modifier(Modifier::DIM)),
Span::styled(
" Now with GPT-5-Codex!",
" NEW with GPT-5-Codex!",
Style::default().fg(crate::colors::primary()),
),
]));
@@ -5083,15 +5083,6 @@ fn popular_commands_lines() -> Vec<Line<'static>> {
Span::from(SlashCommand::Chrome.description())
.style(Style::default().add_modifier(Modifier::DIM)),
]));
lines.push(Line::from(vec![
Span::styled(
"/browser <url>",
Style::default().fg(crate::colors::primary()),
),
Span::from(" - "),
Span::from(SlashCommand::Browser.description())
.style(Style::default().add_modifier(Modifier::DIM)),
]));
lines.push(Line::from(vec![
Span::styled("/plan", Style::default().fg(crate::colors::primary())),
Span::from(" - "),
@@ -5110,6 +5101,12 @@ fn popular_commands_lines() -> Vec<Line<'static>> {
Span::from(SlashCommand::Code.description())
.style(Style::default().add_modifier(Modifier::DIM)),
]));
lines.push(Line::from(vec![
Span::styled("/branch", Style::default().fg(crate::colors::primary())),
Span::from(" - "),
Span::from(SlashCommand::Branch.description())
.style(Style::default().add_modifier(Modifier::DIM)),
]));
lines.push(Line::from(vec![
Span::styled("/resume", Style::default().fg(crate::colors::primary())),
Span::from(" - "),
@@ -7250,7 +7247,7 @@ pub(crate) fn new_plan_update(update: UpdatePlanArgs) -> PlanUpdateCell {
header.push(Span::styled(
"Update plan",
Style::default()
.fg(crate::colors::primary())
.fg(crate::colors::success())
.add_modifier(Modifier::BOLD),
));
header.push(Span::raw(" ["));
@@ -7275,7 +7272,6 @@ pub(crate) fn new_plan_update(update: UpdatePlanArgs) -> PlanUpdateCell {
let t = s.trim().to_string();
if t.is_empty() { None } else { Some(t) }
}) {
lines.push(Line::from("note".dim().italic()));
for l in expl.lines() {
lines.push(Line::from(l.to_string()).dim());
}
@@ -7331,7 +7327,7 @@ pub(crate) fn new_patch_event(
) -> PatchSummaryCell {
let title = match event_type {
PatchEventType::ApprovalRequest => "proposed patch".to_string(),
PatchEventType::ApplyBegin { .. } => "Updating...".to_string(),
PatchEventType::ApplyBegin { .. } => "Updated".to_string(),
};
let kind = match event_type {
PatchEventType::ApprovalRequest => PatchKind::Proposed,
+5 -1
View File
@@ -32,6 +32,7 @@ pub enum SlashCommand {
Perf,
Agents,
Branch,
Merge,
Github,
Mcp,
Resume,
@@ -68,7 +69,10 @@ impl SlashCommand {
SlashCommand::Prompts => "show example prompts",
SlashCommand::Model => "choose model & reasoning effort",
SlashCommand::Agents => "create and configure agents",
SlashCommand::Branch => "create a worktree branch and switch (/branch [task])",
SlashCommand::Branch => {
"work in an isolated /branch then /merge when done (great for parallel work)"
}
SlashCommand::Merge => "merge current worktree branch back to default",
SlashCommand::Github => "GitHub Actions watcher (status/on/off)",
SlashCommand::Mcp => "manage MCP servers (status/on/off/add)",
SlashCommand::Perf => "performance tracing (on/off/show/reset)",
+4 -1
View File
@@ -25,7 +25,10 @@ Notes
- `/init`: create an `AGENTS.md` file with instructions for Codex.
- `/diff`: show `git diff` (including untracked files).
- `/branch [task]`: create a worktree branch and switch to it. If a
task/description is provided, it is used when naming the branch.
task/description is provided, it is used when naming the branch. Must be run
from the repository root (not inside another branch worktree).
- `/merge`: merge the current worktree branch back into the default branch and
remove the worktree. Run this from inside the worktree created by `/branch`.
## UX & Display