Files
dev/code-rs/core/src/git_worktree.rs
T

528 lines
20 KiB
Rust

use base64::Engine;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::fs as stdfs;
use std::path::{Path, PathBuf};
use tokio::fs::OpenOptions;
use tokio::process::Command;
/// Returns the `/.../.code/branches/<worktree>` root when `path` resides inside a branch worktree.
pub fn branch_worktree_root(path: &Path) -> Option<PathBuf> {
use std::ffi::OsStr;
let mut candidate: Option<PathBuf> = None;
for ancestor in path.ancestors() {
let parent = ancestor.parent()?;
if parent
.file_name()
.map(|name| name == OsStr::new("branches"))
.unwrap_or(false)
{
let mut higher = parent.parent();
while let Some(dir) = higher {
if dir
.file_name()
.map(|name| name == OsStr::new(".code"))
.unwrap_or(false)
{
candidate = Some(ancestor.to_path_buf());
break;
}
higher = dir.parent();
}
}
}
candidate
}
/// Returns true when `path` resides inside a `/.../.code/branches/<worktree>` directory.
pub fn is_branch_worktree_path(path: &Path) -> bool {
branch_worktree_root(path).is_some()
}
/// 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)
}
pub const LOCAL_DEFAULT_REMOTE: &str = "local-default";
const BRANCH_METADATA_DIR: &str = "_branch-meta";
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct BranchMetadata {
#[serde(skip_serializing_if = "Option::is_none")]
pub base_branch: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub remote_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub remote_ref: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub remote_url: Option<String>,
}
/// 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;
}
}
pub async fn ensure_local_default_remote(
git_root: &Path,
base_branch: Option<&str>,
) -> Result<Option<BranchMetadata>, String> {
let remote_name = LOCAL_DEFAULT_REMOTE;
let canonical_root = tokio::fs::canonicalize(git_root)
.await
.unwrap_or_else(|_| git_root.to_path_buf());
let remote_url = canonical_root.to_string_lossy().to_string();
let remote_check = Command::new("git")
.current_dir(git_root)
.args(["remote", "get-url", remote_name])
.output()
.await;
match remote_check {
Ok(out) if out.status.success() => {
let existing = String::from_utf8_lossy(&out.stdout).trim().to_string();
if existing != remote_url {
let update = Command::new("git")
.current_dir(git_root)
.args(["remote", "set-url", remote_name, &remote_url])
.output()
.await
.map_err(|e| format!("Failed to set {remote_name} URL: {e}"))?;
if !update.status.success() {
let stderr = String::from_utf8_lossy(&update.stderr).trim().to_string();
return Err(format!("Failed to set {remote_name} URL: {stderr}"));
}
}
}
_ => {
let add = Command::new("git")
.current_dir(git_root)
.args(["remote", "add", remote_name, &remote_url])
.output()
.await
.map_err(|e| format!("Failed to add {remote_name}: {e}"))?;
if !add.status.success() {
let stderr = String::from_utf8_lossy(&add.stderr).trim().to_string();
return Err(format!("Failed to add {remote_name}: {stderr}"));
}
}
}
let base_branch_clean = base_branch
.map(|s| s.trim())
.filter(|s| !s.is_empty() && *s != "HEAD")
.map(|s| s.to_string());
let base_branch_clean = match base_branch_clean {
Some(value) => Some(value),
None => detect_default_branch(git_root).await,
};
let mut metadata = BranchMetadata {
base_branch: base_branch_clean.clone(),
remote_name: Some(remote_name.to_string()),
remote_ref: None,
remote_url: Some(remote_url),
};
if let Some(base) = base_branch_clean {
let commit = Command::new("git")
.current_dir(git_root)
.args(["rev-parse", "--verify", &base])
.output()
.await
.map_err(|e| format!("Failed to resolve base branch {base}: {e}"))?;
if commit.status.success() {
let sha = String::from_utf8_lossy(&commit.stdout).trim().to_string();
if !sha.is_empty() {
let remote_ref = format!("refs/remotes/{remote_name}/{base}");
let update = Command::new("git")
.current_dir(git_root)
.args(["update-ref", &remote_ref, &sha])
.output()
.await
.map_err(|e| format!("Failed to update {remote_ref}: {e}"))?;
if update.status.success() {
metadata.remote_ref = Some(format!("{remote_name}/{base}"));
}
}
}
}
Ok(Some(metadata))
}
fn canonical_worktree_path(worktree_path: &Path) -> Option<PathBuf> {
stdfs::canonicalize(worktree_path)
.ok()
.or_else(|| Some(worktree_path.to_path_buf()))
}
fn metadata_file_path(worktree_path: &Path) -> Option<PathBuf> {
let canonical = canonical_worktree_path(worktree_path)?;
let mut base = dirs::home_dir()?;
base = base
.join(".code")
.join("working")
.join(BRANCH_METADATA_DIR);
let key = canonical.to_string_lossy();
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(key.as_bytes());
Some(base.join(encoded).with_extension("json"))
}
pub async fn write_branch_metadata(
worktree_path: &Path,
metadata: &BranchMetadata,
) -> Result<(), String> {
let Some(path) = metadata_file_path(worktree_path) else { return Ok(()); };
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| format!("Failed to prepare branch metadata directory: {e}"))?;
}
let serialised = serde_json::to_vec_pretty(metadata)
.map_err(|e| format!("Failed to serialise branch metadata: {e}"))?;
let legacy_path = worktree_path.join(".codex-branch.json");
let _ = tokio::fs::remove_file(&legacy_path).await;
tokio::fs::write(&path, serialised)
.await
.map_err(|e| format!("Failed to write branch metadata: {e}"))
}
pub fn load_branch_metadata(worktree_path: &Path) -> Option<BranchMetadata> {
if let Some(path) = metadata_file_path(worktree_path) {
if let Ok(bytes) = stdfs::read(&path) {
if let Ok(parsed) = serde_json::from_slice(&bytes) {
return Some(parsed);
}
}
}
let legacy_path = worktree_path.join(".codex-branch.json");
let bytes = stdfs::read(legacy_path).ok()?;
serde_json::from_slice(&bytes).ok()
}
pub fn remove_branch_metadata(worktree_path: &Path) {
if let Some(path) = metadata_file_path(worktree_path) {
let _ = stdfs::remove_file(path);
}
let legacy_path = worktree_path.join(".codex-branch.json");
let _ = stdfs::remove_file(legacy_path);
}
/// 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);
let meta = match tokio::fs::metadata(&from).await { Ok(m) => m, Err(_) => continue };
if !meta.is_file() { continue; }
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)),
}
}
// Opt-in: mirror modified submodule pointers into the worktree index (no checkout/network).
// Enable via CODEX_BRANCH_INCLUDE_SUBMODULES=1|true|yes.
let include_submods = std::env::var("CODEX_BRANCH_INCLUDE_SUBMODULES")
.ok()
.map(|v| v.to_ascii_lowercase())
.map(|v| v == "1" || v == "true" || v == "yes")
.unwrap_or(false);
if include_submods {
if let Ok(out) = Command::new("git")
.current_dir(src_root)
.args(["submodule", "status", "--recursive"])
.output()
.await
{
if out.status.success() {
let text = String::from_utf8_lossy(&out.stdout);
for line in text.lines() {
let line = line.trim();
if !line.starts_with('+') { continue; }
let rest = &line[1..];
let mut parts = rest.split_whitespace();
let sha = match parts.next() { Some(s) => s, None => continue };
let path = match parts.next() { Some(p) => p, None => continue };
let spec = format!("160000,{},{}", sha, path);
let _ = Command::new("git")
.current_dir(worktree_path)
.args(["update-index", "--add", "--cacheinfo", &spec])
.output()
.await;
}
}
}
}
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
}