feat(tui/branch): add /branch command to create worktree, switch session, and optional auto-run task; add finalize flow to merge to default and cleanup; refactor worktree helpers into core util and reuse in agent tool

This commit is contained in:
James Peter
2025-09-08 09:53:23 +10:00
parent 1d8182ba52
commit 8f888de155
7 changed files with 353 additions and 66 deletions
+2 -66
View File
@@ -5,7 +5,6 @@ use serde::Deserialize;
use serde::Serialize;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::process::Command;
@@ -368,26 +367,7 @@ async fn get_git_root() -> Result<PathBuf, String> {
}
}
fn sanitize_ref_component(s: &str) -> String {
// Git refname safe component: [a-z0-9-]+, collapse invalid runs to '-'
let mut out = String::with_capacity(s.len());
let mut last_dash = false;
for ch in s.chars() {
let c = ch.to_ascii_lowercase();
let valid = c.is_ascii_alphanumeric() || c == '-';
if valid {
out.push(c);
last_dash = c == '-';
} else if !last_dash {
out.push('-');
last_dash = true;
}
}
// Trim leading/trailing '-'
let out = out.trim_matches('-').to_string();
// Avoid empty component
if out.is_empty() { "agent".to_string() } else { out }
}
use crate::git_worktree::sanitize_ref_component;
fn generate_branch_id(model: &str, agent: &str) -> String {
// Extract first few meaningful words from agent for the branch name
@@ -425,51 +405,7 @@ fn generate_branch_id(model: &str, agent: &str) -> String {
format!("code-{}-{}", model_s, suffix_s)
}
async fn setup_worktree(git_root: &Path, branch_id: &str) -> Result<PathBuf, String> {
// Create .code/branches directory if it doesn't exist
let code_dir = git_root.join(".code").join("branches");
tokio::fs::create_dir_all(&code_dir)
.await
.map_err(|e| format!("Failed to create .code/branches directory: {}", e))?;
// Path for this model's worktree
let worktree_path = code_dir.join(branch_id);
// Remove existing worktree if it exists (cleanup from previous runs)
if worktree_path.exists() {
Command::new("git")
.args(&[
"worktree",
"remove",
worktree_path.to_str().unwrap(),
"--force",
])
.output()
.await
.ok(); // Ignore errors, it might not be a worktree
}
// Create new worktree
let output = Command::new("git")
.current_dir(git_root)
.args(&[
"worktree",
"add",
"-b",
branch_id,
worktree_path.to_str().unwrap(),
])
.output()
.await
.map_err(|e| format!("Failed to create git worktree: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Failed to create worktree: {}", stderr));
}
Ok(worktree_path)
}
use crate::git_worktree::setup_worktree;
async fn execute_agent(agent_id: String, config: Option<AgentConfig>) {
let mut manager = AGENT_MANAGER.write().await;
+163
View File
@@ -0,0 +1,163 @@
use chrono::Utc;
use std::path::{Path, PathBuf};
use tokio::process::Command;
/// Sanitize a string to be used as a single git refname component.
///
/// Converts to lowercase, keeps [a-z0-9-], collapses other runs into '-'.
pub fn sanitize_ref_component(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut last_dash = false;
for ch in s.chars() {
let c = ch.to_ascii_lowercase();
let valid = c.is_ascii_alphanumeric() || c == '-';
if valid {
out.push(c);
last_dash = c == '-';
} else if !last_dash {
out.push('-');
last_dash = true;
}
}
let out = out.trim_matches('-').to_string();
if out.is_empty() { "branch".to_string() } else { out }
}
/// Generate a branch name for a generic task. If `task` is None or cannot
/// produce a meaningful slug, fall back to a timestamp.
pub fn generate_branch_name_from_task(task: Option<&str>) -> String {
if let Some(task) = task {
let stop = ["the", "and", "for", "with", "from", "into", "goal"];
let words: Vec<&str> = task
.split_whitespace()
.filter(|w| w.len() > 2 && !stop.contains(&w.to_ascii_lowercase().as_str()))
.take(4)
.collect();
if !words.is_empty() {
let mut slug = sanitize_ref_component(&words.join("-"));
if slug.len() > 48 {
slug.truncate(48);
slug = slug.trim_matches('-').to_string();
if slug.is_empty() { slug = "branch".to_string(); }
}
return format!("code-branch-{}", slug);
}
}
// Fallback: timestamped id
let ts = Utc::now().format("%Y%m%d-%H%M%S");
format!("code-branch-{}", ts)
}
/// Resolve the git repository root (top-level) for the given cwd.
pub async fn get_git_root_from(cwd: &Path) -> Result<PathBuf, String> {
let output = Command::new("git")
.arg("rev-parse")
.arg("--show-toplevel")
.current_dir(cwd)
.output()
.await
.map_err(|e| format!("Git not installed or not in a git repository: {}", e))?;
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
Ok(PathBuf::from(path))
} else {
Err("Not in a git repository".to_string())
}
}
/// Create a new worktree for `branch_id` under `<git_root>/.code/branches/<branch_id>`.
/// If a previous worktree directory exists, remove it first.
pub async fn setup_worktree(git_root: &Path, branch_id: &str) -> Result<PathBuf, String> {
let code_dir = git_root.join(".code").join("branches");
tokio::fs::create_dir_all(&code_dir)
.await
.map_err(|e| format!("Failed to create .code/branches directory: {}", e))?;
let worktree_path = code_dir.join(branch_id);
if worktree_path.exists() {
let _ = Command::new("git")
.arg("worktree")
.arg("remove")
.arg(worktree_path.to_str().unwrap())
.arg("--force")
.current_dir(git_root)
.output()
.await; // ignore errors
}
let output = Command::new("git")
.current_dir(git_root)
.args(["worktree", "add", "-b", branch_id, worktree_path.to_str().unwrap()])
.output()
.await
.map_err(|e| format!("Failed to create git worktree: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Failed to create worktree: {}", stderr));
}
Ok(worktree_path)
}
/// Copy uncommitted (modified + untracked) files from `src_root` into the `worktree_path`.
/// Returns the number of files copied.
pub async fn copy_uncommitted_to_worktree(src_root: &Path, worktree_path: &Path) -> Result<usize, String> {
// List modified and other (untracked) files relative to repo root
let output = Command::new("git")
.current_dir(src_root)
.args(["ls-files", "-om", "--exclude-standard", "-z"])
.output()
.await
.map_err(|e| format!("Failed to list changes: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("git ls-files failed: {}", stderr));
}
let bytes = output.stdout;
let mut count = 0usize;
for path_bytes in bytes.split(|b| *b == 0) {
if path_bytes.is_empty() { continue; }
let rel = match String::from_utf8(path_bytes.to_vec()) { Ok(s) => s, Err(_) => continue };
// Avoid copying .git files explicitly
if rel.starts_with(".git/") { continue; }
let from = src_root.join(&rel);
let to = worktree_path.join(&rel);
if let Some(parent) = to.parent() { tokio::fs::create_dir_all(parent).await.map_err(|e| format!("Failed to create dir {}: {}", parent.display(), e))?; }
// Use copy for files; skip if it's a directory (shouldn't appear from ls-files)
match tokio::fs::copy(&from, &to).await {
Ok(_) => count += 1,
Err(e) => return Err(format!("Failed to copy {} -> {}: {}", from.display(), to.display(), e)),
}
}
Ok(count)
}
/// Determine repository default branch. Prefers `origin/HEAD` symbolic ref, then local `main`/`master`.
pub async fn detect_default_branch(cwd: &Path) -> Option<String> {
// Try origin/HEAD first
let sym = Command::new("git")
.current_dir(cwd)
.args(["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"])
.output()
.await
.ok()?;
if sym.status.success() {
if let Ok(s) = String::from_utf8(sym.stdout) {
if let Some((_, name)) = s.trim().rsplit_once('/') { return Some(name.to_string()); }
}
}
// Fallback to local main/master
for candidate in ["main", "master"] {
let out = Command::new("git")
.current_dir(cwd)
.args(["rev-parse", "--verify", "--quiet", &format!("refs/heads/{candidate}")])
.output()
.await
.ok()?;
if out.status.success() { return Some(candidate.to_string()); }
}
None
}
+1
View File
@@ -33,6 +33,7 @@ mod exec_command;
pub mod exec_env;
mod flags;
pub mod git_info;
pub mod git_worktree;
mod image_comparison;
mod is_safe_command;
pub mod landlock;
+31
View File
@@ -657,6 +657,11 @@ impl App<'_> {
};
match command {
SlashCommand::Branch => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_branch_command(command_args);
}
}
SlashCommand::Resume => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.show_resume_picker();
@@ -834,6 +839,32 @@ impl App<'_> {
}
}
}
AppEvent::SwitchCwd(new_cwd, initial_prompt) => {
// Rebuild the chat widget bound to a new cwd, preserving
// current configuration and terminal properties.
let mut cfg = self.config.clone();
cfg.cwd = new_cwd;
let mut new_widget = ChatWidget::new(
cfg,
self.app_event_tx.clone(),
None,
Vec::new(),
self.enhanced_keys_supported,
self.terminal_info.clone(),
self.show_order_overlay,
);
new_widget.enable_perf(self.timing_enabled);
self.app_state = AppState::Chat { widget: Box::new(new_widget) };
// Optionally submit a prompt immediately in the new session
if let AppState::Chat { widget } = &mut self.app_state {
if let Some(prompt) = initial_prompt {
if !prompt.is_empty() {
widget.submit_text_message(prompt);
}
}
}
self.app_event_tx.send(AppEvent::RequestRedraw);
}
AppEvent::ResumeFrom(path) => {
// Replace the current chat widget with a new one configured to resume
let mut cfg = self.config.clone();
+5
View File
@@ -63,6 +63,11 @@ pub(crate) enum AppEvent {
/// layer so it can be handled centrally. Includes the full command text.
DispatchCommand(SlashCommand, String),
/// Switch to a new working directory by rebuilding the chat widget with
/// the same configuration but a different `cwd`. Optionally submits an
/// initial prompt once the new session is ready.
SwitchCwd(std::path::PathBuf, Option<String>),
/// Signal that agents are about to start (triggered when /plan, /solve, /code commands are entered)
PrepareAgents,
+149
View File
@@ -6056,6 +6056,155 @@ impl ChatWidget<'_> {
}
}
impl ChatWidget<'_> {
/// Handle `/branch [task]` command. Creates a worktree under `.code/branches`,
/// 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();
return;
}
let cwd = self.config.cwd.clone();
let tx = self.app_event_tx.clone();
// Add a quick notice into history
self.history_push(crate::history_cell::new_background_event("Creating branch worktree...".to_string()));
self.request_redraw();
tokio::spawn(async move {
// Resolve git root
let git_root = match codex_core::git_worktree::get_git_root_from(&cwd).await {
Ok(p) => p,
Err(e) => {
tx.send(AppEvent::InsertHistory(vec![ratatui::text::Line::from(format!("`/branch` — not a git repo: {}", e))]));
return;
}
};
// Determine branch name
let task_opt = if args.trim().is_empty() { None } else { Some(args.trim()) };
let branch_name = codex_core::git_worktree::generate_branch_name_from_task(task_opt);
// Create worktree
let worktree = match codex_core::git_worktree::setup_worktree(&git_root, &branch_name).await {
Ok(p) => p,
Err(e) => {
tx.send(AppEvent::InsertHistory(vec![ratatui::text::Line::from(format!("`/branch` — failed to create worktree: {}", e))]));
return;
}
};
// Copy uncommitted changes from the source root into the new worktree
let copied = match codex_core::git_worktree::copy_uncommitted_to_worktree(&git_root, &worktree).await {
Ok(n) => n,
Err(e) => {
tx.send(AppEvent::InsertHistory(vec![ratatui::text::Line::from(format!("`/branch` — failed to copy changes: {}", e))]));
// Still switch to the branch even if copy fails
0
}
};
let msg = if task_opt.is_some() {
format!(
"Created worktree '{}'\nPath: {}\nCopied {} changed files\nSwitching and starting task...",
branch_name,
worktree.display(),
copied
)
} else {
format!(
"Created worktree '{}'\nPath: {}\nCopied {} changed files\nSwitched to branch. Type your task when ready.",
branch_name,
worktree.display(),
copied
)
};
tx.send(AppEvent::InsertHistory(vec![ratatui::text::Line::from(msg)]));
// Switch cwd and optionally submit the task
let initial_prompt = task_opt.map(|s| s.to_string());
tx.send(AppEvent::SwitchCwd(worktree, initial_prompt));
});
}
/// 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) {
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()));
self.request_redraw();
tokio::spawn(async move {
use tokio::process::Command;
// Resolve repo root and current branch name
let git_root = match codex_core::git_worktree::get_git_root_from(&work_cwd).await {
Ok(p) => p,
Err(e) => {
tx.send(AppEvent::InsertHistory(vec![ratatui::text::Line::from(format!("`/branch finalize` — not a git repo: {}", e))]));
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(),
_ => {
tx.send(AppEvent::InsertHistory(vec![ratatui::text::Line::from("`/branch finalize` — failed to detect branch name") ]));
return;
}
};
// Commit any pending changes in the worktree
let _ = Command::new("git").current_dir(&work_cwd).args(["add", "-A"]).output().await;
let commit_out = Command::new("git")
.current_dir(&work_cwd)
.args(["commit", "-m", &format!("merge {branch_name} via /branch")])
.output()
.await;
if let Ok(o) = &commit_out {
if !o.status.success() {
// Ignore "nothing to commit" (exit 1); surface others
let msg = String::from_utf8_lossy(&o.stderr);
if !msg.contains("nothing to commit") {
tx.send(AppEvent::InsertHistory(vec![ratatui::text::Line::from(format!("`/branch finalize` — commit failed: {}", msg.trim()))]));
}
}
}
// Determine default branch in main repo
let default_branch = match codex_core::git_worktree::detect_default_branch(&git_root).await {
Some(b) => b,
None => {
tx.send(AppEvent::InsertHistory(vec![ratatui::text::Line::from("`/branch finalize` — failed to determine default branch (tried origin/HEAD, main, master)")]));
return;
}
};
// Merge branch into default from the main repo root
let co = Command::new("git").current_dir(&git_root).args(["checkout", &default_branch]).output().await;
if !matches!(co, Ok(ref o) if o.status.success()) {
tx.send(AppEvent::InsertHistory(vec![ratatui::text::Line::from("`/branch finalize` — failed to checkout default branch") ]));
return;
}
let merge = Command::new("git").current_dir(&git_root).args(["merge", "--no-ff", &branch_name]).output().await;
if !matches!(merge, Ok(ref o) if o.status.success()) {
let err = merge.ok().and_then(|o| String::from_utf8(o.stderr).ok()).unwrap_or_else(|| "unknown error".to_string());
tx.send(AppEvent::InsertHistory(vec![ratatui::text::Line::from(format!("`/branch finalize` — merge failed: {}", err.trim()))]));
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"]).output().await;
let _ = Command::new("git").current_dir(&git_root).args(["branch", "-D", &branch_name]).output().await;
// Inform user and switch back to git root
let msg = format!("Merged '{}' into '{}' and cleaned up worktree. Switching back to {}", branch_name, default_branch, git_root.display());
tx.send(AppEvent::InsertHistory(vec![ratatui::text::Line::from(msg.clone())]));
tx.send(AppEvent::SwitchCwd(git_root, None));
});
}
}
#[cfg(test)]
mod tests {
use super::*;
+2
View File
@@ -26,6 +26,7 @@ pub enum SlashCommand {
Prompts,
Perf,
Agents,
Branch,
Github,
Resume,
// Prompt-expanding commands
@@ -60,6 +61,7 @@ impl SlashCommand {
SlashCommand::Theme => "switch between color themes",
SlashCommand::Prompts => "show example prompts",
SlashCommand::Agents => "list agents (running and availability)",
SlashCommand::Branch => "create a worktree branch and switch (/branch [task])",
SlashCommand::Github => "GitHub Actions watcher (status/on/off)",
SlashCommand::Perf => "performance tracing (on/off/show/reset)",
SlashCommand::Logout => "log out of Codex",