mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user