mirror of
https://github.com/openai/codex.git
synced 2026-11-09 20:50:33 +00:00
core: add experimental per-session git worktree mode
Introduce WorktreeHandle to create/reuse/remove linked checkouts under <cwd>/codex/<conversation>; add config flag enable_git_worktree (and trust logic for worktrees); plumb protocol: RemoveWorktree, WorktreeRemoved, SessionConfigured.worktree_path; update exec/TUI integration; docs: experimental config section. # Conflicts: # codex-rs/core/src/codex.rs # codex-rs/tui/src/chatwidget.rs
This commit is contained in:
@@ -0,0 +1,355 @@
|
||||
use std::fs as std_fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use codex_protocol::mcp_protocol::ConversationId;
|
||||
use tokio::fs;
|
||||
use tokio::fs::OpenOptions;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::process::Command;
|
||||
use tracing::info;
|
||||
use tracing::warn;
|
||||
|
||||
/// Represents a linked git worktree managed by Codex.
|
||||
///
|
||||
/// The handle tracks whether Codex created the worktree for the current
|
||||
/// conversation. It leaves the checkout in place until [`remove`] is invoked.
|
||||
pub struct WorktreeHandle {
|
||||
repo_root: PathBuf,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl WorktreeHandle {
|
||||
/// Create (or reuse) a worktree rooted at
|
||||
/// `<repo_root>/codex/worktree/<conversation_id>`.
|
||||
pub async fn create(repo_root: &Path, conversation_id: &ConversationId) -> Result<Self> {
|
||||
if !repo_root.exists() {
|
||||
return Err(anyhow!(
|
||||
"git worktree root `{}` does not exist",
|
||||
repo_root.display()
|
||||
));
|
||||
}
|
||||
|
||||
let repo_root = repo_root.to_path_buf();
|
||||
let codex_dir = repo_root.join("codex");
|
||||
let codex_worktree_dir = codex_dir.join("worktree");
|
||||
fs::create_dir_all(&codex_worktree_dir)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to create codex worktree directory at `{}`",
|
||||
codex_worktree_dir.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
let path = codex_worktree_dir.join(conversation_id.to_string());
|
||||
let is_registered = worktree_registered(&repo_root, &path).await?;
|
||||
|
||||
if is_registered {
|
||||
if path.exists() {
|
||||
if let Err(err) = ensure_codex_excluded(&repo_root).await {
|
||||
warn!("failed to add codex worktree path to git exclude: {err:#}");
|
||||
}
|
||||
info!(
|
||||
worktree = %path.display(),
|
||||
"reusing existing git worktree for conversation"
|
||||
);
|
||||
return Ok(Self { repo_root, path });
|
||||
}
|
||||
|
||||
warn!(
|
||||
worktree = %path.display(),
|
||||
"git worktree is registered but missing on disk; pruning stale entry"
|
||||
);
|
||||
run_git_command(&repo_root, ["worktree", "prune", "--expire", "now"])
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to prune git worktrees while recovering `{}`",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
if worktree_registered(&repo_root, &path).await? {
|
||||
return Err(anyhow!(
|
||||
"git worktree `{}` is registered but missing on disk; run `git worktree prune --expire now` to remove the stale entry",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
|
||||
info!(
|
||||
worktree = %path.display(),
|
||||
"recreating git worktree for conversation after pruning stale registration"
|
||||
);
|
||||
}
|
||||
|
||||
if path.exists() {
|
||||
return Err(anyhow!(
|
||||
"git worktree path `{}` already exists but is not registered; remove it manually",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
|
||||
run_git_command(
|
||||
&repo_root,
|
||||
[
|
||||
"worktree",
|
||||
"add",
|
||||
"--detach",
|
||||
path.to_str().ok_or_else(|| {
|
||||
anyhow!(
|
||||
"failed to convert worktree path `{}` to UTF-8",
|
||||
path.display()
|
||||
)
|
||||
})?,
|
||||
"HEAD",
|
||||
],
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("failed to create git worktree at `{}`", path.display()))?;
|
||||
|
||||
if let Err(err) = ensure_codex_excluded(&repo_root).await {
|
||||
warn!("failed to add codex worktree path to git exclude: {err:#}");
|
||||
}
|
||||
|
||||
info!(
|
||||
worktree = %path.display(),
|
||||
"created git worktree for conversation"
|
||||
);
|
||||
|
||||
Ok(Self { repo_root, path })
|
||||
}
|
||||
|
||||
/// Absolute path to the worktree checkout on disk.
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// Remove the worktree and prune metadata from the repository.
|
||||
pub async fn remove(self) -> Result<()> {
|
||||
let path = self.path.clone();
|
||||
|
||||
// `git worktree remove` fails if refs are missing or the checkout is dirty.
|
||||
// Use --force to ensure best effort removal; the user explicitly requested it.
|
||||
run_git_command(
|
||||
&self.repo_root,
|
||||
[
|
||||
"worktree",
|
||||
"remove",
|
||||
"--force",
|
||||
path.to_str().ok_or_else(|| {
|
||||
anyhow!(
|
||||
"failed to convert worktree path `{}` to UTF-8",
|
||||
path.display()
|
||||
)
|
||||
})?,
|
||||
],
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("failed to remove git worktree `{}`", path.display()))?;
|
||||
|
||||
// Prune dangling metadata so repeated sessions do not accumulate entries.
|
||||
if let Err(err) =
|
||||
run_git_command(&self.repo_root, ["worktree", "prune", "--expire", "now"]).await
|
||||
{
|
||||
warn!("failed to prune git worktrees: {err:#}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn worktree_registered(repo_root: &Path, target: &Path) -> Result<bool> {
|
||||
let output = run_git_command(repo_root, ["worktree", "list", "--porcelain"]).await?;
|
||||
let stdout = String::from_utf8(output.stdout)?;
|
||||
|
||||
let target_canon = std_fs::canonicalize(target).unwrap_or_else(|_| target.to_path_buf());
|
||||
|
||||
for line in stdout.lines() {
|
||||
if let Some(path) = line.strip_prefix("worktree ") {
|
||||
let candidate = Path::new(path);
|
||||
let candidate_canon =
|
||||
std_fs::canonicalize(candidate).unwrap_or_else(|_| candidate.to_path_buf());
|
||||
if candidate_canon == target_canon {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn run_git_command<'a>(
|
||||
repo_root: &Path,
|
||||
args: impl IntoIterator<Item = &'a str>,
|
||||
) -> Result<std::process::Output> {
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.args(args);
|
||||
cmd.current_dir(repo_root);
|
||||
let output = cmd
|
||||
.output()
|
||||
.await
|
||||
.with_context(|| format!("failed to execute git command in `{}`", repo_root.display()))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
let status = output
|
||||
.status
|
||||
.code()
|
||||
.map(|c| c.to_string())
|
||||
.unwrap_or_else(|| "signal".to_string());
|
||||
return Err(anyhow!("git command exited with status {status}: {stderr}",));
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
async fn ensure_codex_excluded(repo_root: &Path) -> Result<()> {
|
||||
const PATTERN: &str = "/codex/";
|
||||
|
||||
let git_dir_out = run_git_command(repo_root, ["rev-parse", "--git-dir"]).await?;
|
||||
let git_dir_str = String::from_utf8(git_dir_out.stdout)?.trim().to_string();
|
||||
let git_dir_path = if Path::new(&git_dir_str).is_absolute() {
|
||||
PathBuf::from(&git_dir_str)
|
||||
} else {
|
||||
repo_root.join(&git_dir_str)
|
||||
};
|
||||
|
||||
let info_dir = git_dir_path.join("info");
|
||||
fs::create_dir_all(&info_dir).await?;
|
||||
let exclude_path = info_dir.join("exclude");
|
||||
|
||||
let existing_bytes = fs::read(&exclude_path).await.unwrap_or_default();
|
||||
let existing = String::from_utf8(existing_bytes).unwrap_or_default();
|
||||
if existing.lines().any(|line| line.trim() == PATTERN) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&exclude_path)
|
||||
.await?;
|
||||
|
||||
if !existing.is_empty() && !existing.ends_with('\n') {
|
||||
file.write_all(b"\n").await?;
|
||||
}
|
||||
file.write_all(PATTERN.as_bytes()).await?;
|
||||
file.write_all(b"\n").await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
const GIT_ENV: [(&str, &str); 2] = [
|
||||
("GIT_CONFIG_GLOBAL", "/dev/null"),
|
||||
("GIT_CONFIG_NOSYSTEM", "1"),
|
||||
];
|
||||
|
||||
async fn init_repo() -> (TempDir, PathBuf) {
|
||||
let temp = TempDir::new().expect("tempdir");
|
||||
let repo_path = temp.path().join("repo");
|
||||
fs::create_dir_all(&repo_path)
|
||||
.await
|
||||
.expect("create repo dir");
|
||||
|
||||
run_git_with_env(&repo_path, ["init"], &GIT_ENV)
|
||||
.await
|
||||
.expect("git init");
|
||||
run_git_with_env(&repo_path, ["config", "user.name", "Test User"], &GIT_ENV)
|
||||
.await
|
||||
.expect("config user.name");
|
||||
run_git_with_env(
|
||||
&repo_path,
|
||||
["config", "user.email", "test@example.com"],
|
||||
&GIT_ENV,
|
||||
)
|
||||
.await
|
||||
.expect("config user.email");
|
||||
|
||||
fs::write(repo_path.join("README.md"), b"hello world")
|
||||
.await
|
||||
.expect("write file");
|
||||
run_git_with_env(&repo_path, ["add", "README.md"], &GIT_ENV)
|
||||
.await
|
||||
.expect("git add");
|
||||
run_git_with_env(&repo_path, ["commit", "-m", "init"], &GIT_ENV)
|
||||
.await
|
||||
.expect("git commit");
|
||||
|
||||
(temp, repo_path)
|
||||
}
|
||||
|
||||
async fn run_git_with_env<'a>(
|
||||
cwd: &Path,
|
||||
args: impl IntoIterator<Item = &'a str>,
|
||||
envs: &[(&str, &str)],
|
||||
) -> Result<()> {
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.args(args);
|
||||
cmd.current_dir(cwd);
|
||||
for (key, value) in envs {
|
||||
cmd.env(key, value);
|
||||
}
|
||||
let status = cmd.status().await.context("failed to spawn git command")?;
|
||||
if !status.success() {
|
||||
return Err(anyhow!(
|
||||
"git command exited with status {status} (cwd: {})",
|
||||
cwd.display()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn is_registered(repo_root: &Path, path: &Path) -> bool {
|
||||
worktree_registered(repo_root, path).await.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn creates_and_removes_worktree() {
|
||||
let (_temp, repo) = init_repo().await;
|
||||
let conversation_id = ConversationId::new();
|
||||
|
||||
let handle = WorktreeHandle::create(&repo, &conversation_id)
|
||||
.await
|
||||
.expect("create worktree");
|
||||
let path = handle.path().to_path_buf();
|
||||
assert!(path.exists(), "worktree path should exist on disk");
|
||||
assert!(
|
||||
is_registered(&repo, &path).await,
|
||||
"worktree should be registered"
|
||||
);
|
||||
|
||||
handle.remove().await.expect("remove worktree");
|
||||
assert!(
|
||||
!is_registered(&repo, &path).await,
|
||||
"worktree should be removed from registration"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn reuses_existing_worktree() {
|
||||
let (_temp, repo) = init_repo().await;
|
||||
let conversation_id = ConversationId::new();
|
||||
|
||||
let first = WorktreeHandle::create(&repo, &conversation_id)
|
||||
.await
|
||||
.expect("create worktree");
|
||||
let path = first.path().to_path_buf();
|
||||
drop(first);
|
||||
|
||||
let second = WorktreeHandle::create(&repo, &conversation_id)
|
||||
.await
|
||||
.expect("reuse worktree");
|
||||
assert_eq!(path, second.path());
|
||||
assert!(is_registered(&repo, second.path()).await);
|
||||
|
||||
second.remove().await.expect("remove worktree");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user