Files
dev/codex-rs/core/src/git_worktree.rs
T
James Peter 2908be4501 feat(tui/core): show Popular commands on start; track and clean worktrees
- Insert Popular commands prelude immediately to avoid waiting for MCP
- Avoid duplicate prelude when SessionConfigured arrives; show model change
- Set upstream for new worktree branches; include status in background message
- Improve  checkout flow and worktree hinting
- Record created worktrees in per-process session file
- Clean up session worktrees on TUI exit (post-restore)
2025-09-08 22:04:54 +10:00

293 lines
12 KiB
Rust

use chrono::Utc;
use std::path::{Path, PathBuf};
use tokio::process::Command;
use tokio::fs::OpenOptions;
/// 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), String> {
// Global location: ~/.code/working/<repo_name>/branches
let repo_name = git_root
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("repo");
let mut code_dir = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
code_dir = code_dir
.join(".code")
.join("working")
.join(repo_name)
.join("branches");
tokio::fs::create_dir_all(&code_dir)
.await
.map_err(|e| format!("Failed to create .code/branches directory: {}", e))?;
let mut effective_branch = branch_id.to_string();
let mut worktree_path = code_dir.join(&effective_branch);
if worktree_path.exists() {
// If the worktree directory already exists, re-use it to avoid the cost
// of removing and re-adding a worktree. This makes repeated agent runs
// start much faster.
record_worktree_in_session(git_root, &worktree_path).await;
return Ok((worktree_path, effective_branch));
}
let output = Command::new("git")
.current_dir(git_root)
.args(["worktree", "add", "-b", &effective_branch, 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);
// If the branch already exists, generate a unique name and retry once.
if stderr.contains("already exists") {
effective_branch = format!("{}-{}", effective_branch, Utc::now().format("%Y%m%d-%H%M%S"));
worktree_path = code_dir.join(&effective_branch);
// Ensure target path is clean
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;
}
let retry = Command::new("git")
.current_dir(git_root)
.args(["worktree", "add", "-b", &effective_branch, worktree_path.to_str().unwrap()])
.output()
.await
.map_err(|e| format!("Failed to create git worktree (retry): {}", e))?;
if !retry.status.success() {
let retry_err = String::from_utf8_lossy(&retry.stderr);
return Err(format!("Failed to create worktree: {}", retry_err));
}
record_worktree_in_session(git_root, &worktree_path).await;
} else {
return Err(format!("Failed to create worktree: {}", stderr));
}
}
// Skip remote alias setup for speed; we don't need it during agent runs.
// Record created worktree for this process; best-effort.
record_worktree_in_session(git_root, &worktree_path).await;
Ok((worktree_path, effective_branch))
}
/// Append the created worktree to a per-process session file so the TUI can
/// clean it up on exit without touching worktrees from other processes.
async fn record_worktree_in_session(git_root: &Path, worktree_path: &Path) {
let pid = std::process::id();
let mut base = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
// Global session registry: ~/.code/working/_session
base = base.join(".code").join("working").join("_session");
if let Err(_e) = tokio::fs::create_dir_all(&base).await { return; }
let file = base.join(format!("pid-{}.txt", pid));
// Store git_root and worktree_path separated by a tab; one entry per line.
if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(&file).await {
let line = format!("{}\t{}\n", git_root.display(), worktree_path.display());
let _ = tokio::io::AsyncWriteExt::write_all(&mut f, line.as_bytes()).await;
}
}
/// Ensure a remote named `origin` exists. If it's missing, choose a likely
/// writable remote (prefer `fork`, then `upstream-push`, then the first push
/// URL we find) and alias it as `origin`. Finally, set the remote HEAD so
/// `origin/HEAD` points at the default branch.
async fn _ensure_origin_remote(git_root: &Path) -> Result<(), String> {
// Check existing remotes
let remotes_out = Command::new("git")
.current_dir(git_root)
.args(["remote"])
.output()
.await
.map_err(|e| format!("git remote failed: {}", e))?;
if !remotes_out.status.success() {
return Err("git remote returned error".to_string());
}
let remotes: Vec<String> = String::from_utf8_lossy(&remotes_out.stdout)
.lines()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if remotes.iter().any(|r| r == "origin") {
// Make sure origin/HEAD is set; ignore errors
let _ = Command::new("git")
.current_dir(git_root)
.args(["remote", "set-head", "origin", "-a"])
.output()
.await;
return Ok(());
}
// Prefer candidates in this order
let mut candidates = vec!["fork", "upstream-push", "upstream"]; // typical setups
// Append any other remotes as fallbacks
for r in &remotes {
if !candidates.contains(&r.as_str()) { candidates.push(r); }
}
// Find a candidate with a URL
for cand in candidates {
let url_out = Command::new("git")
.current_dir(git_root)
.args(["remote", "get-url", cand])
.output()
.await;
if let Ok(out) = url_out {
if out.status.success() {
let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !url.is_empty() {
// Add origin pointing to this URL
let add = Command::new("git")
.current_dir(git_root)
.args(["remote", "add", "origin", &url])
.output()
.await
.map_err(|e| format!("git remote add origin failed: {}", e))?;
if !add.status.success() {
return Err("failed to add origin".to_string());
}
let _ = Command::new("git")
.current_dir(git_root)
.args(["remote", "set-head", "origin", "-a"])
.output()
.await;
return Ok(());
}
}
}
}
// No usable remote found; leave as-is
Err("no suitable remote to alias as origin".to_string())
}
/// 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
}