mirror of
https://github.com/openai/codex.git
synced 2026-11-12 21:20:34 +00:00
feat(tui): add managed worktree flow
This commit is contained in:
@@ -0,0 +1,516 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use codex_worktree::DirtyPolicy;
|
||||
use codex_worktree::WorktreeListQuery;
|
||||
use codex_worktree::WorktreeLocation;
|
||||
use codex_worktree::WorktreeRemoveRequest;
|
||||
use codex_worktree::WorktreeRequest;
|
||||
use codex_worktree::WorktreeSource;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn creates_reuses_lists_and_removes_managed_worktree() -> anyhow::Result<()> {
|
||||
let fixture = GitFixture::new()?;
|
||||
fs::create_dir(fixture.repo.path().join("codex-rs"))?;
|
||||
fs::write(fixture.repo.path().join("codex-rs/README.md"), "subdir\n")?;
|
||||
run_git(fixture.repo.path(), &["add", "codex-rs/README.md"])?;
|
||||
run_git(fixture.repo.path(), &["commit", "-m", "add subdir"])?;
|
||||
|
||||
let resolution = codex_worktree::ensure_worktree(WorktreeRequest {
|
||||
codex_home: fixture.codex_home.path().to_path_buf(),
|
||||
source_cwd: fixture.repo.path().join("codex-rs"),
|
||||
branch: "parser-fix".to_string(),
|
||||
base_ref: None,
|
||||
dirty_policy: DirtyPolicy::Fail,
|
||||
})?;
|
||||
|
||||
assert!(!resolution.reused);
|
||||
assert_eq!(resolution.info.name, "parser-fix");
|
||||
assert_eq!(resolution.info.slug, "parser-fix");
|
||||
assert_eq!(resolution.info.branch.as_deref(), Some("parser-fix"));
|
||||
assert_eq!(resolution.info.source, WorktreeSource::Cli);
|
||||
assert_eq!(resolution.info.location, WorktreeLocation::Sibling);
|
||||
let canonical_repo = fixture.repo.path().canonicalize()?;
|
||||
assert_eq!(
|
||||
resolution.info.worktree_git_root,
|
||||
canonical_repo.with_file_name(format!(
|
||||
"{}.parser-fix",
|
||||
canonical_repo.file_name().unwrap().to_string_lossy()
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
resolution.info.workspace_cwd,
|
||||
resolution.info.worktree_git_root.join("codex-rs")
|
||||
);
|
||||
assert!(resolution.info.workspace_cwd.exists());
|
||||
assert!(
|
||||
git(
|
||||
&resolution.info.worktree_git_root,
|
||||
&["rev-parse", "--git-path", "codex-worktree.json"]
|
||||
)
|
||||
.map(|path| resolution.info.worktree_git_root.join(path).exists())
|
||||
.unwrap_or(false)
|
||||
);
|
||||
|
||||
let reused = codex_worktree::ensure_worktree(WorktreeRequest {
|
||||
codex_home: fixture.codex_home.path().to_path_buf(),
|
||||
source_cwd: fixture.repo.path().join("codex-rs"),
|
||||
branch: "parser-fix".to_string(),
|
||||
base_ref: None,
|
||||
dirty_policy: DirtyPolicy::Fail,
|
||||
})?;
|
||||
assert!(reused.reused);
|
||||
assert_eq!(
|
||||
reused.info.worktree_git_root,
|
||||
resolution.info.worktree_git_root
|
||||
);
|
||||
|
||||
let entries = codex_worktree::list_worktrees(WorktreeListQuery {
|
||||
codex_home: fixture.codex_home.path().to_path_buf(),
|
||||
source_cwd: Some(fixture.repo.path().to_path_buf()),
|
||||
include_all_repos: false,
|
||||
})?;
|
||||
assert_eq!(
|
||||
entries
|
||||
.iter()
|
||||
.filter(|entry| entry.source == WorktreeSource::Cli)
|
||||
.map(|entry| entry.branch.as_deref())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![Some("parser-fix")]
|
||||
);
|
||||
|
||||
let removed = codex_worktree::remove_worktree(WorktreeRemoveRequest {
|
||||
codex_home: fixture.codex_home.path().to_path_buf(),
|
||||
source_cwd: Some(fixture.repo.path().to_path_buf()),
|
||||
name_or_path: "parser-fix".to_string(),
|
||||
force: false,
|
||||
delete_branch: false,
|
||||
})?;
|
||||
assert_eq!(removed.removed_path, resolution.info.worktree_git_root);
|
||||
assert!(!removed.removed_path.exists());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creates_sibling_from_sibling_using_primary_repo_name() -> anyhow::Result<()> {
|
||||
let fixture = GitFixture::new()?;
|
||||
let first = codex_worktree::ensure_worktree(WorktreeRequest {
|
||||
codex_home: fixture.codex_home.path().to_path_buf(),
|
||||
source_cwd: fixture.repo.path().to_path_buf(),
|
||||
branch: "fcoury/worktrees".to_string(),
|
||||
base_ref: None,
|
||||
dirty_policy: DirtyPolicy::Fail,
|
||||
})?;
|
||||
|
||||
let second = codex_worktree::ensure_worktree(WorktreeRequest {
|
||||
codex_home: fixture.codex_home.path().to_path_buf(),
|
||||
source_cwd: first.info.workspace_cwd,
|
||||
branch: "fcoury/test".to_string(),
|
||||
base_ref: None,
|
||||
dirty_policy: DirtyPolicy::Fail,
|
||||
})?;
|
||||
|
||||
let canonical_repo = fixture.repo.path().canonicalize()?;
|
||||
assert_eq!(
|
||||
second.info.worktree_git_root,
|
||||
canonical_repo.with_file_name(format!(
|
||||
"{}.fcoury-test",
|
||||
canonical_repo.file_name().unwrap().to_string_lossy()
|
||||
))
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_tracked_preserves_staged_and_unstaged_diffs() -> anyhow::Result<()> {
|
||||
let fixture = GitFixture::new()?;
|
||||
fs::write(fixture.repo.path().join("staged.txt"), "staged changed\n")?;
|
||||
run_git(fixture.repo.path(), &["add", "staged.txt"])?;
|
||||
fs::write(
|
||||
fixture.repo.path().join("unstaged.txt"),
|
||||
"unstaged changed\n",
|
||||
)?;
|
||||
|
||||
let resolution = codex_worktree::ensure_worktree(WorktreeRequest {
|
||||
codex_home: fixture.codex_home.path().to_path_buf(),
|
||||
source_cwd: fixture.repo.path().to_path_buf(),
|
||||
branch: "copy-dirty".to_string(),
|
||||
base_ref: None,
|
||||
dirty_policy: DirtyPolicy::CopyTracked,
|
||||
})?;
|
||||
|
||||
assert_eq!(
|
||||
git(
|
||||
&resolution.info.worktree_git_root,
|
||||
&["diff", "--cached", "--name-only"]
|
||||
)?,
|
||||
"staged.txt"
|
||||
);
|
||||
assert_eq!(
|
||||
git(&resolution.info.worktree_git_root, &["diff", "--name-only"])?,
|
||||
"unstaged.txt"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_all_transfers_dirty_state_and_cleans_source_checkout() -> anyhow::Result<()> {
|
||||
let fixture = GitFixture::new()?;
|
||||
fs::write(fixture.repo.path().join(".gitignore"), "ignored.txt\n")?;
|
||||
run_git(fixture.repo.path(), &["add", ".gitignore"])?;
|
||||
run_git(fixture.repo.path(), &["commit", "-m", "ignore fixture"])?;
|
||||
fs::write(fixture.repo.path().join("staged.txt"), "staged changed\n")?;
|
||||
run_git(fixture.repo.path(), &["add", "staged.txt"])?;
|
||||
fs::write(
|
||||
fixture.repo.path().join("unstaged.txt"),
|
||||
"unstaged changed\n",
|
||||
)?;
|
||||
fs::write(fixture.repo.path().join("untracked.txt"), "untracked\n")?;
|
||||
fs::write(fixture.repo.path().join("ignored.txt"), "ignored\n")?;
|
||||
|
||||
let resolution = codex_worktree::ensure_worktree(WorktreeRequest {
|
||||
codex_home: fixture.codex_home.path().to_path_buf(),
|
||||
source_cwd: fixture.repo.path().to_path_buf(),
|
||||
branch: "move-dirty".to_string(),
|
||||
base_ref: /*base_ref*/ None,
|
||||
dirty_policy: DirtyPolicy::MoveAll,
|
||||
})?;
|
||||
|
||||
assert_eq!(
|
||||
git(
|
||||
&resolution.info.worktree_git_root,
|
||||
&["diff", "--cached", "--name-only"]
|
||||
)?,
|
||||
"staged.txt"
|
||||
);
|
||||
assert_eq!(
|
||||
git(&resolution.info.worktree_git_root, &["diff", "--name-only"])?,
|
||||
"unstaged.txt"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(resolution.info.worktree_git_root.join("untracked.txt"))?,
|
||||
"untracked\n"
|
||||
);
|
||||
assert!(
|
||||
!resolution
|
||||
.info
|
||||
.worktree_git_root
|
||||
.join("ignored.txt")
|
||||
.exists()
|
||||
);
|
||||
assert_eq!(git(fixture.repo.path(), &["status", "--short"])?, "");
|
||||
assert!(fixture.repo.path().join("ignored.txt").exists());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_tracked_transfers_tracked_state_and_leaves_untracked_files() -> anyhow::Result<()> {
|
||||
let fixture = GitFixture::new()?;
|
||||
fs::write(fixture.repo.path().join("staged.txt"), "staged changed\n")?;
|
||||
run_git(fixture.repo.path(), &["add", "staged.txt"])?;
|
||||
fs::write(
|
||||
fixture.repo.path().join("unstaged.txt"),
|
||||
"unstaged changed\n",
|
||||
)?;
|
||||
fs::write(fixture.repo.path().join("untracked.txt"), "untracked\n")?;
|
||||
|
||||
let resolution = codex_worktree::ensure_worktree(WorktreeRequest {
|
||||
codex_home: fixture.codex_home.path().to_path_buf(),
|
||||
source_cwd: fixture.repo.path().to_path_buf(),
|
||||
branch: "move-tracked".to_string(),
|
||||
base_ref: /*base_ref*/ None,
|
||||
dirty_policy: DirtyPolicy::MoveTracked,
|
||||
})?;
|
||||
|
||||
assert_eq!(
|
||||
git(
|
||||
&resolution.info.worktree_git_root,
|
||||
&["diff", "--cached", "--name-only"]
|
||||
)?,
|
||||
"staged.txt"
|
||||
);
|
||||
assert_eq!(
|
||||
git(&resolution.info.worktree_git_root, &["diff", "--name-only"])?,
|
||||
"unstaged.txt"
|
||||
);
|
||||
assert!(
|
||||
!resolution
|
||||
.info
|
||||
.worktree_git_root
|
||||
.join("untracked.txt")
|
||||
.exists()
|
||||
);
|
||||
assert_eq!(
|
||||
git(fixture.repo.path(), &["status", "--short"])?,
|
||||
"?? untracked.txt"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_transfer_rolls_back_new_worktree_and_branch() -> anyhow::Result<()> {
|
||||
let fixture = GitFixture::new_with_base_before_tracked_file()?;
|
||||
fs::write(fixture.repo.path().join("tracked.txt"), "changed\n")?;
|
||||
|
||||
let err = codex_worktree::ensure_worktree(WorktreeRequest {
|
||||
codex_home: fixture.codex_home.path().to_path_buf(),
|
||||
source_cwd: fixture.repo.path().to_path_buf(),
|
||||
branch: "rollback-created".to_string(),
|
||||
base_ref: Some("HEAD~1".to_string()),
|
||||
dirty_policy: DirtyPolicy::CopyTracked,
|
||||
})
|
||||
.expect_err("tracked patch should not apply against an older base");
|
||||
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("failed to apply unstaged changes to worktree"),
|
||||
"{err:#}"
|
||||
);
|
||||
assert!(
|
||||
!fixture
|
||||
.repo
|
||||
.path()
|
||||
.with_file_name(format!(
|
||||
"{}.rollback-created",
|
||||
fixture.repo.path().file_name().unwrap().to_string_lossy()
|
||||
))
|
||||
.exists()
|
||||
);
|
||||
assert!(!branch_exists(fixture.repo.path(), "rollback-created")?);
|
||||
assert_eq!(
|
||||
git(fixture.repo.path(), &["status", "--short"])?,
|
||||
" M tracked.txt"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_transfer_preserves_preexisting_branch() -> anyhow::Result<()> {
|
||||
let fixture = GitFixture::new_with_base_before_tracked_file()?;
|
||||
run_git(fixture.repo.path(), &["branch", "keep-me", "HEAD~1"])?;
|
||||
fs::write(fixture.repo.path().join("tracked.txt"), "changed\n")?;
|
||||
|
||||
codex_worktree::ensure_worktree(WorktreeRequest {
|
||||
codex_home: fixture.codex_home.path().to_path_buf(),
|
||||
source_cwd: fixture.repo.path().to_path_buf(),
|
||||
branch: "keep-me".to_string(),
|
||||
base_ref: /*base_ref*/ None,
|
||||
dirty_policy: DirtyPolicy::CopyTracked,
|
||||
})
|
||||
.expect_err("tracked patch should not apply against the preexisting branch");
|
||||
|
||||
assert!(branch_exists(fixture.repo.path(), "keep-me")?);
|
||||
assert!(
|
||||
!fixture
|
||||
.repo
|
||||
.path()
|
||||
.with_file_name(format!(
|
||||
"{}.keep-me",
|
||||
fixture.repo.path().file_name().unwrap().to_string_lossy()
|
||||
))
|
||||
.exists()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creates_orphan_worktree_from_unborn_repo_without_base_ref() -> anyhow::Result<()> {
|
||||
let fixture = GitFixture::new_unborn()?;
|
||||
fs::write(fixture.repo.path().join("README.md"), "hello\n")?;
|
||||
|
||||
let resolution = codex_worktree::ensure_worktree(WorktreeRequest {
|
||||
codex_home: fixture.codex_home.path().to_path_buf(),
|
||||
source_cwd: fixture.repo.path().to_path_buf(),
|
||||
branch: "feature".to_string(),
|
||||
base_ref: /*base_ref*/ None,
|
||||
dirty_policy: DirtyPolicy::CopyAll,
|
||||
})?;
|
||||
|
||||
assert_eq!(
|
||||
git(
|
||||
&resolution.info.worktree_git_root,
|
||||
&["status", "--short", "--branch"]
|
||||
)?,
|
||||
"## No commits yet on feature\n?? README.md"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_all_cleans_unborn_source_checkout() -> anyhow::Result<()> {
|
||||
let fixture = GitFixture::new_unborn()?;
|
||||
fs::write(fixture.repo.path().join("staged.txt"), "staged\n")?;
|
||||
run_git(fixture.repo.path(), &["add", "staged.txt"])?;
|
||||
fs::write(fixture.repo.path().join("untracked.txt"), "untracked\n")?;
|
||||
|
||||
let resolution = codex_worktree::ensure_worktree(WorktreeRequest {
|
||||
codex_home: fixture.codex_home.path().to_path_buf(),
|
||||
source_cwd: fixture.repo.path().to_path_buf(),
|
||||
branch: "feature".to_string(),
|
||||
base_ref: /*base_ref*/ None,
|
||||
dirty_policy: DirtyPolicy::MoveAll,
|
||||
})?;
|
||||
|
||||
assert_eq!(
|
||||
git(
|
||||
&resolution.info.worktree_git_root,
|
||||
&["status", "--short", "--branch"]
|
||||
)?,
|
||||
"## No commits yet on feature\nA staged.txt\n?? untracked.txt"
|
||||
);
|
||||
assert_eq!(
|
||||
git(fixture.repo.path(), &["status", "--short", "--branch"])?,
|
||||
"## No commits yet on main"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_sibling_path_collision_for_different_branch() -> anyhow::Result<()> {
|
||||
let fixture = GitFixture::new()?;
|
||||
let resolution = codex_worktree::ensure_worktree(WorktreeRequest {
|
||||
codex_home: fixture.codex_home.path().to_path_buf(),
|
||||
source_cwd: fixture.repo.path().to_path_buf(),
|
||||
branch: "feature/foo".to_string(),
|
||||
base_ref: None,
|
||||
dirty_policy: DirtyPolicy::Fail,
|
||||
})?;
|
||||
|
||||
let err = codex_worktree::ensure_worktree(WorktreeRequest {
|
||||
codex_home: fixture.codex_home.path().to_path_buf(),
|
||||
source_cwd: fixture.repo.path().to_path_buf(),
|
||||
branch: "feature-foo".to_string(),
|
||||
base_ref: None,
|
||||
dirty_policy: DirtyPolicy::Fail,
|
||||
})
|
||||
.expect_err("sanitized branch path collision should fail");
|
||||
|
||||
assert!(
|
||||
err.to_string().contains("already used by feature/foo"),
|
||||
"{err:#}"
|
||||
);
|
||||
let removed = codex_worktree::remove_worktree(WorktreeRemoveRequest {
|
||||
codex_home: fixture.codex_home.path().to_path_buf(),
|
||||
source_cwd: Some(fixture.repo.path().to_path_buf()),
|
||||
name_or_path: "feature/foo".to_string(),
|
||||
force: false,
|
||||
delete_branch: false,
|
||||
})?;
|
||||
assert_eq!(removed.removed_path, resolution.info.worktree_git_root);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_includes_app_style_worktrees_without_cli_metadata() -> anyhow::Result<()> {
|
||||
let fixture = GitFixture::new()?;
|
||||
let app_worktree = fixture.codex_home.path().join("worktrees/7f6e/repo");
|
||||
fs::create_dir_all(app_worktree.parent().expect("app worktree parent"))?;
|
||||
run_git(
|
||||
fixture.repo.path(),
|
||||
&[
|
||||
"worktree",
|
||||
"add",
|
||||
app_worktree.to_str().expect("utf-8 path"),
|
||||
"HEAD",
|
||||
],
|
||||
)?;
|
||||
|
||||
let entries = codex_worktree::list_worktrees(WorktreeListQuery {
|
||||
codex_home: fixture.codex_home.path().to_path_buf(),
|
||||
source_cwd: Some(fixture.repo.path().to_path_buf()),
|
||||
include_all_repos: false,
|
||||
})?;
|
||||
|
||||
let canonical_app_worktree = app_worktree.canonicalize()?;
|
||||
assert_eq!(
|
||||
entries
|
||||
.iter()
|
||||
.filter(|entry| entry.source == WorktreeSource::App)
|
||||
.map(|entry| (entry.name.as_str(), entry.worktree_git_root.as_path()))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![("repo", canonical_app_worktree.as_path())]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct GitFixture {
|
||||
repo: TempDir,
|
||||
codex_home: TempDir,
|
||||
}
|
||||
|
||||
impl GitFixture {
|
||||
fn new() -> anyhow::Result<Self> {
|
||||
let repo = TempDir::new()?;
|
||||
let codex_home = TempDir::new()?;
|
||||
run_git(repo.path(), &["init", "-b", "main"])?;
|
||||
run_git(repo.path(), &["config", "user.email", "codex@example.com"])?;
|
||||
run_git(repo.path(), &["config", "user.name", "Codex"])?;
|
||||
fs::write(repo.path().join("staged.txt"), "staged original\n")?;
|
||||
fs::write(repo.path().join("unstaged.txt"), "unstaged original\n")?;
|
||||
run_git(repo.path(), &["add", "."])?;
|
||||
run_git(repo.path(), &["commit", "-m", "initial"])?;
|
||||
Ok(Self { repo, codex_home })
|
||||
}
|
||||
|
||||
fn new_unborn() -> anyhow::Result<Self> {
|
||||
let repo = TempDir::new()?;
|
||||
let codex_home = TempDir::new()?;
|
||||
run_git(repo.path(), &["init", "-b", "main"])?;
|
||||
run_git(repo.path(), &["config", "user.email", "codex@example.com"])?;
|
||||
run_git(repo.path(), &["config", "user.name", "Codex"])?;
|
||||
Ok(Self { repo, codex_home })
|
||||
}
|
||||
|
||||
fn new_with_base_before_tracked_file() -> anyhow::Result<Self> {
|
||||
let repo = TempDir::new()?;
|
||||
let codex_home = TempDir::new()?;
|
||||
run_git(repo.path(), &["init", "-b", "main"])?;
|
||||
run_git(repo.path(), &["config", "user.email", "codex@example.com"])?;
|
||||
run_git(repo.path(), &["config", "user.name", "Codex"])?;
|
||||
run_git(repo.path(), &["commit", "--allow-empty", "-m", "base"])?;
|
||||
fs::write(repo.path().join("tracked.txt"), "original\n")?;
|
||||
run_git(repo.path(), &["add", "tracked.txt"])?;
|
||||
run_git(repo.path(), &["commit", "-m", "add tracked file"])?;
|
||||
Ok(Self { repo, codex_home })
|
||||
}
|
||||
}
|
||||
|
||||
fn run_git(cwd: &Path, args: &[&str]) -> anyhow::Result<()> {
|
||||
let output = Command::new("git").args(args).current_dir(cwd).output()?;
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"git {} failed: {}",
|
||||
args.join(" "),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn git(cwd: &Path, args: &[&str]) -> anyhow::Result<String> {
|
||||
let output = Command::new("git").args(args).current_dir(cwd).output()?;
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"git {} failed: {}",
|
||||
args.join(" "),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
Ok(String::from_utf8(output.stdout)?.trim_end().to_string())
|
||||
}
|
||||
|
||||
fn branch_exists(cwd: &Path, branch: &str) -> anyhow::Result<bool> {
|
||||
let output = Command::new("git")
|
||||
.args([
|
||||
"show-ref",
|
||||
"--verify",
|
||||
"--quiet",
|
||||
&format!("refs/heads/{branch}"),
|
||||
])
|
||||
.current_dir(cwd)
|
||||
.output()?;
|
||||
Ok(output.status.success())
|
||||
}
|
||||
Reference in New Issue
Block a user