mirror of
https://github.com/openai/codex.git
synced 2026-10-22 17:58:46 +00:00
feat(cli): add managed worktree workflow
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
use std::fs;
|
||||
use std::path::Component;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::git;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DirtyPolicy {
|
||||
Fail,
|
||||
Ignore,
|
||||
CopyTracked,
|
||||
CopyAll,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DirtyState {
|
||||
pub has_staged_changes: bool,
|
||||
pub has_unstaged_changes: bool,
|
||||
pub has_untracked_files: bool,
|
||||
}
|
||||
|
||||
impl DirtyState {
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
self.has_staged_changes || self.has_unstaged_changes || self.has_untracked_files
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dirty_state(root: &Path) -> Result<DirtyState> {
|
||||
let staged = git::bytes(root, &["diff", "--cached", "--name-only", "-z"])?;
|
||||
let unstaged = git::bytes(root, &["diff", "--name-only", "-z"])?;
|
||||
let untracked = git::bytes(root, &["ls-files", "--others", "--exclude-standard", "-z"])?;
|
||||
Ok(DirtyState {
|
||||
has_staged_changes: !staged.is_empty(),
|
||||
has_unstaged_changes: !unstaged.is_empty(),
|
||||
has_untracked_files: !untracked.is_empty(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn validate_dirty_policy_before_create(
|
||||
source_root: &Path,
|
||||
policy: DirtyPolicy,
|
||||
) -> Result<Vec<String>> {
|
||||
let state = dirty_state(source_root)?;
|
||||
if !state.is_dirty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
match policy {
|
||||
DirtyPolicy::Fail => bail_for_dirty_source(),
|
||||
DirtyPolicy::Ignore => Ok(vec![
|
||||
"source checkout has uncommitted changes; the new worktree was created without them"
|
||||
.to_string(),
|
||||
]),
|
||||
DirtyPolicy::CopyTracked => {
|
||||
if state.has_untracked_files {
|
||||
Ok(vec![
|
||||
"untracked files were left in the source checkout; use --worktree-dirty copy-all to copy them"
|
||||
.to_string(),
|
||||
])
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
DirtyPolicy::CopyAll => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_dirty_policy_after_create(
|
||||
source_root: &Path,
|
||||
worktree_root: &Path,
|
||||
policy: DirtyPolicy,
|
||||
) -> Result<()> {
|
||||
let state = dirty_state(source_root)?;
|
||||
if !state.is_dirty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match policy {
|
||||
DirtyPolicy::Fail | DirtyPolicy::Ignore => Ok(()),
|
||||
DirtyPolicy::CopyTracked => apply_tracked_diff(source_root, worktree_root),
|
||||
DirtyPolicy::CopyAll => {
|
||||
apply_tracked_diff(source_root, worktree_root)?;
|
||||
copy_untracked_files(source_root, worktree_root)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn bail_for_dirty_source<T>() -> Result<T> {
|
||||
anyhow::bail!(
|
||||
"source checkout has uncommitted changes; use --worktree-dirty ignore, copy-tracked, or copy-all"
|
||||
);
|
||||
}
|
||||
|
||||
fn apply_tracked_diff(source_root: &Path, worktree_root: &Path) -> Result<()> {
|
||||
let staged = git::bytes(source_root, &["diff", "--cached", "--binary"])?;
|
||||
let unstaged = git::bytes(source_root, &["diff", "--binary"])?;
|
||||
|
||||
if !staged.is_empty() {
|
||||
git::status_with_stdin(
|
||||
worktree_root,
|
||||
&["apply", "--index", "--binary", "-"],
|
||||
&staged,
|
||||
)
|
||||
.context("failed to apply staged changes to worktree")?;
|
||||
}
|
||||
if !unstaged.is_empty() {
|
||||
git::status_with_stdin(worktree_root, &["apply", "--binary", "-"], &unstaged)
|
||||
.context("failed to apply unstaged changes to worktree")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_untracked_files(source_root: &Path, worktree_root: &Path) -> Result<()> {
|
||||
let output = git::bytes(
|
||||
source_root,
|
||||
&["ls-files", "--others", "--exclude-standard", "-z"],
|
||||
)?;
|
||||
for raw_path in output
|
||||
.split(|byte| *byte == 0)
|
||||
.filter(|path| !path.is_empty())
|
||||
{
|
||||
let relative_path = PathBuf::from(String::from_utf8_lossy(raw_path).into_owned());
|
||||
ensure_safe_relative_path(&relative_path)?;
|
||||
let source = source_root.join(&relative_path);
|
||||
let destination = worktree_root.join(&relative_path);
|
||||
let metadata = fs::symlink_metadata(&source)?;
|
||||
if let Some(parent) = destination.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
if metadata.file_type().is_symlink() {
|
||||
let target = fs::read_link(&source)?;
|
||||
create_symlink(&target, &destination)?;
|
||||
} else if metadata.is_file() {
|
||||
fs::copy(&source, &destination)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_safe_relative_path(path: &Path) -> Result<()> {
|
||||
if path.is_absolute() {
|
||||
anyhow::bail!(
|
||||
"refusing to copy absolute untracked path {}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
if path.components().any(|component| {
|
||||
matches!(component, Component::ParentDir)
|
||||
|| matches!(component, Component::Normal(value) if value == ".git")
|
||||
}) {
|
||||
anyhow::bail!("refusing to copy unsafe untracked path {}", path.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn create_symlink(target: &Path, destination: &Path) -> Result<()> {
|
||||
std::os::unix::fs::symlink(target, destination).map_err(Into::into)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn create_symlink(target: &Path, destination: &Path) -> Result<()> {
|
||||
std::os::windows::fs::symlink_file(target, destination).map_err(Into::into)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dirty_state_reports_clean_by_default() {
|
||||
assert!(!DirtyState::default().is_dirty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::process::Stdio;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
|
||||
pub fn stdout(cwd: &Path, args: &[&str]) -> Result<String> {
|
||||
let output = output(cwd, args)?;
|
||||
Ok(String::from_utf8(output)?.trim_end().to_string())
|
||||
}
|
||||
|
||||
pub fn bytes(cwd: &Path, args: &[&str]) -> Result<Vec<u8>> {
|
||||
output(cwd, args)
|
||||
}
|
||||
|
||||
pub fn status(cwd: &Path, args: &[&str]) -> Result<()> {
|
||||
output(cwd, args).map(|_| ())
|
||||
}
|
||||
|
||||
pub fn status_with_stdin(cwd: &Path, args: &[&str], stdin: &[u8]) -> Result<()> {
|
||||
let mut child = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(cwd)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.with_context(|| format!("failed to spawn git {}", args.join(" ")))?;
|
||||
use std::io::Write as _;
|
||||
child
|
||||
.stdin
|
||||
.as_mut()
|
||||
.context("git stdin unavailable")?
|
||||
.write_all(stdin)?;
|
||||
let output = child.wait_with_output()?;
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"git {} failed: {}",
|
||||
args.join(" "),
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn output(cwd: &Path, args: &[&str]) -> Result<Vec<u8>> {
|
||||
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).trim()
|
||||
);
|
||||
}
|
||||
Ok(output.stdout)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
mod dirty;
|
||||
mod git;
|
||||
mod manager;
|
||||
mod metadata;
|
||||
mod paths;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
pub use dirty::DirtyPolicy;
|
||||
pub use dirty::DirtyState;
|
||||
pub use manager::ensure_worktree;
|
||||
pub use manager::list_worktrees;
|
||||
pub use manager::remove_worktree;
|
||||
pub use manager::resolve_worktree;
|
||||
pub use metadata::bind_thread;
|
||||
pub use metadata::read_worktree_metadata;
|
||||
pub use metadata::write_worktree_metadata;
|
||||
pub use paths::codex_worktrees_root;
|
||||
pub use paths::is_managed_worktree_path;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WorktreeRequest {
|
||||
pub codex_home: PathBuf,
|
||||
pub source_cwd: PathBuf,
|
||||
pub branch: String,
|
||||
pub base_ref: Option<String>,
|
||||
pub dirty_policy: DirtyPolicy,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WorktreeResolution {
|
||||
pub reused: bool,
|
||||
pub info: WorktreeInfo,
|
||||
pub warnings: Vec<WorktreeWarning>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WorktreeInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub source: WorktreeSource,
|
||||
pub location: WorktreeLocation,
|
||||
pub repo_name: String,
|
||||
pub repo_root: PathBuf,
|
||||
pub common_git_dir: PathBuf,
|
||||
pub worktree_git_root: PathBuf,
|
||||
pub workspace_cwd: PathBuf,
|
||||
pub original_relative_cwd: PathBuf,
|
||||
pub branch: Option<String>,
|
||||
pub head: Option<String>,
|
||||
pub owner_thread_id: Option<String>,
|
||||
pub metadata_path: PathBuf,
|
||||
pub dirty: DirtyState,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum WorktreeSource {
|
||||
Cli,
|
||||
App,
|
||||
Legacy,
|
||||
Git,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum WorktreeLocation {
|
||||
Sibling,
|
||||
CodexHome,
|
||||
External,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WorktreeWarning {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WorktreeListQuery {
|
||||
pub codex_home: PathBuf,
|
||||
pub source_cwd: Option<PathBuf>,
|
||||
pub include_all_repos: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WorktreeRemoveRequest {
|
||||
pub codex_home: PathBuf,
|
||||
pub source_cwd: Option<PathBuf>,
|
||||
pub name_or_path: String,
|
||||
pub force: bool,
|
||||
pub delete_branch: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WorktreeRemoveResult {
|
||||
pub removed_path: PathBuf,
|
||||
pub deleted_branch: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::WorktreeInfo;
|
||||
use crate::WorktreeListQuery;
|
||||
use crate::WorktreeLocation;
|
||||
use crate::WorktreeRemoveRequest;
|
||||
use crate::WorktreeRemoveResult;
|
||||
use crate::WorktreeRequest;
|
||||
use crate::WorktreeResolution;
|
||||
use crate::WorktreeSource;
|
||||
use crate::WorktreeWarning;
|
||||
use crate::dirty;
|
||||
use crate::git;
|
||||
use crate::metadata;
|
||||
use crate::metadata::WorktreeMetadata;
|
||||
use crate::paths;
|
||||
|
||||
pub fn ensure_worktree(req: WorktreeRequest) -> Result<WorktreeResolution> {
|
||||
let repo = SourceRepo::resolve(&req.source_cwd)?;
|
||||
let branch = req.branch.clone();
|
||||
let slug = paths::slugify_name(&branch)?;
|
||||
ensure_safe_branch_name(&repo.root, &branch)?;
|
||||
let worktree_git_root = paths::sibling_worktree_git_root(&repo.root, &branch)?;
|
||||
let workspace_cwd = worktree_git_root.join(&repo.relative_cwd);
|
||||
|
||||
if worktree_git_root.exists() {
|
||||
let Some(metadata) = metadata::read_worktree_metadata(&worktree_git_root)? else {
|
||||
anyhow::bail!(
|
||||
"managed worktree path {} already exists but is not owned by Codex",
|
||||
worktree_git_root.display()
|
||||
);
|
||||
};
|
||||
ensure_existing_worktree_matches_branch(&worktree_git_root, &metadata, &branch)?;
|
||||
let info = info_from_existing_worktree(
|
||||
&req.codex_home,
|
||||
&worktree_git_root,
|
||||
Some(branch),
|
||||
Some(slug),
|
||||
)?;
|
||||
return Ok(WorktreeResolution {
|
||||
reused: true,
|
||||
info,
|
||||
warnings: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(path) = branch_checkout_path(&repo.root, &branch)?
|
||||
&& path != worktree_git_root
|
||||
{
|
||||
anyhow::bail!(
|
||||
"branch {branch} is already checked out at {}; remove that worktree first",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
|
||||
let warnings = dirty::validate_dirty_policy_before_create(&repo.root, req.dirty_policy)?;
|
||||
let base_ref = req.base_ref.as_deref().unwrap_or("HEAD");
|
||||
fs::create_dir_all(
|
||||
worktree_git_root
|
||||
.parent()
|
||||
.context("managed worktree path has no parent")?,
|
||||
)?;
|
||||
if branch_exists(&repo.root, &branch) {
|
||||
git::status(
|
||||
&repo.root,
|
||||
&[
|
||||
"worktree",
|
||||
"add",
|
||||
&worktree_git_root.to_string_lossy(),
|
||||
&branch,
|
||||
],
|
||||
)?;
|
||||
} else {
|
||||
git::status(
|
||||
&repo.root,
|
||||
&[
|
||||
"worktree",
|
||||
"add",
|
||||
"-b",
|
||||
&branch,
|
||||
&worktree_git_root.to_string_lossy(),
|
||||
base_ref,
|
||||
],
|
||||
)?;
|
||||
}
|
||||
|
||||
dirty::apply_dirty_policy_after_create(&repo.root, &worktree_git_root, req.dirty_policy)?;
|
||||
let dirty = dirty::dirty_state(&worktree_git_root)?;
|
||||
let head = git::stdout(&worktree_git_root, &["rev-parse", "HEAD"]).ok();
|
||||
let mut info = WorktreeInfo {
|
||||
id: repo.id.clone(),
|
||||
name: branch.clone(),
|
||||
slug,
|
||||
source: WorktreeSource::Cli,
|
||||
location: WorktreeLocation::Sibling,
|
||||
repo_name: repo.repo_name.clone(),
|
||||
repo_root: repo.root.clone(),
|
||||
common_git_dir: repo.common_git_dir.clone(),
|
||||
worktree_git_root: worktree_git_root.clone(),
|
||||
workspace_cwd,
|
||||
original_relative_cwd: repo.relative_cwd.clone(),
|
||||
branch: Some(branch),
|
||||
head,
|
||||
owner_thread_id: None,
|
||||
metadata_path: metadata_path_for_display(&worktree_git_root)?,
|
||||
dirty,
|
||||
};
|
||||
metadata::write_pending_owner_metadata(&worktree_git_root)?;
|
||||
let worktree_metadata = WorktreeMetadata::from_info(&info, repo.root);
|
||||
metadata::write_worktree_metadata(&worktree_git_root, &worktree_metadata)?;
|
||||
info.owner_thread_id = worktree_metadata.owner_thread_id;
|
||||
Ok(WorktreeResolution {
|
||||
reused: false,
|
||||
info,
|
||||
warnings: warnings
|
||||
.into_iter()
|
||||
.map(|message| WorktreeWarning { message })
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn resolve_worktree(codex_home: &Path, cwd: &Path) -> Result<Option<WorktreeInfo>> {
|
||||
let Ok(root) = git::stdout(cwd, &["rev-parse", "--show-toplevel"]) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let root = PathBuf::from(root);
|
||||
if !paths::is_managed_worktree_path(&root, codex_home)
|
||||
&& metadata::read_worktree_metadata(&root)?.is_none()
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(info_from_existing_worktree(
|
||||
codex_home, &root, /*fallback_name*/ None, /*fallback_slug*/ None,
|
||||
)?))
|
||||
}
|
||||
|
||||
pub fn list_worktrees(query: WorktreeListQuery) -> Result<Vec<WorktreeInfo>> {
|
||||
let repo_filter = if query.include_all_repos {
|
||||
None
|
||||
} else {
|
||||
let source_cwd = query
|
||||
.source_cwd
|
||||
.as_ref()
|
||||
.context("source cwd is required unless include_all_repos is true")?;
|
||||
Some(SourceRepo::resolve(source_cwd)?)
|
||||
};
|
||||
let mut entries = Vec::new();
|
||||
if let Some(repo_filter) = repo_filter.as_ref() {
|
||||
for worktree in parse_worktree_list(&git::stdout(
|
||||
&repo_filter.root,
|
||||
&["worktree", "list", "--porcelain"],
|
||||
)?) {
|
||||
let Ok(info) = info_from_existing_worktree(
|
||||
&query.codex_home,
|
||||
worktree.path.as_path(),
|
||||
worktree.branch.clone(),
|
||||
worktree
|
||||
.branch
|
||||
.as_deref()
|
||||
.and_then(|branch| paths::slugify_name(branch).ok()),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
if worktree_matches_repo(&info, repo_filter) {
|
||||
entries.push(info);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let root = paths::codex_worktrees_root(&query.codex_home);
|
||||
if root.exists() {
|
||||
for worktree_root in discover_codex_home_worktree_roots(&root)? {
|
||||
let Ok(info) = info_from_existing_worktree(
|
||||
&query.codex_home,
|
||||
worktree_root.as_path(),
|
||||
/*fallback_name*/ None,
|
||||
/*fallback_slug*/ None,
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(repo_filter) = repo_filter.as_ref()
|
||||
&& !worktree_matches_repo(&info, repo_filter)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
entries.push(info);
|
||||
}
|
||||
}
|
||||
let mut unique_entries = Vec::new();
|
||||
for entry in entries {
|
||||
if unique_entries.iter().any(|existing: &WorktreeInfo| {
|
||||
paths_match(&existing.worktree_git_root, &entry.worktree_git_root)
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
unique_entries.push(entry);
|
||||
}
|
||||
let mut entries = unique_entries;
|
||||
entries.sort_by(|a, b| {
|
||||
display_branch_or_name(a)
|
||||
.cmp(display_branch_or_name(b))
|
||||
.then_with(|| a.worktree_git_root.cmp(&b.worktree_git_root))
|
||||
});
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn discover_codex_home_worktree_roots(root: &Path) -> Result<Vec<PathBuf>> {
|
||||
let mut roots = Vec::new();
|
||||
for parent in fs::read_dir(root)? {
|
||||
let parent = parent?;
|
||||
if !parent.file_type()?.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let parent_path = parent.path();
|
||||
if is_git_root(&parent_path) {
|
||||
roots.push(parent_path);
|
||||
continue;
|
||||
}
|
||||
for child in fs::read_dir(parent_path)? {
|
||||
let child = child?;
|
||||
if !child.file_type()?.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let child_path = child.path();
|
||||
if is_git_root(&child_path) {
|
||||
roots.push(child_path);
|
||||
continue;
|
||||
}
|
||||
for grandchild in fs::read_dir(child_path)? {
|
||||
let grandchild = grandchild?;
|
||||
if !grandchild.file_type()?.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let grandchild_path = grandchild.path();
|
||||
if is_git_root(&grandchild_path) {
|
||||
roots.push(grandchild_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
roots.sort();
|
||||
roots.dedup();
|
||||
Ok(roots)
|
||||
}
|
||||
|
||||
fn is_git_root(path: &Path) -> bool {
|
||||
path.join(".git").exists()
|
||||
}
|
||||
|
||||
fn worktree_matches_repo(info: &WorktreeInfo, repo: &SourceRepo) -> bool {
|
||||
info.id == repo.id || paths_match(&info.common_git_dir, &repo.common_git_dir)
|
||||
}
|
||||
|
||||
fn paths_match(a: &Path, b: &Path) -> bool {
|
||||
let a = a.canonicalize().unwrap_or_else(|_| a.to_path_buf());
|
||||
let b = b.canonicalize().unwrap_or_else(|_| b.to_path_buf());
|
||||
a == b
|
||||
}
|
||||
|
||||
pub fn remove_worktree(req: WorktreeRemoveRequest) -> Result<WorktreeRemoveResult> {
|
||||
let target = target_worktree_path(&req)?;
|
||||
let metadata = metadata::read_worktree_metadata(&target)?
|
||||
.context("refusing to remove a worktree not managed by Codex")?;
|
||||
let dirty = dirty::dirty_state(&target)?;
|
||||
if dirty.is_dirty() && !req.force {
|
||||
anyhow::bail!(
|
||||
"refusing to remove dirty worktree {}; use --force to override",
|
||||
target.display()
|
||||
);
|
||||
}
|
||||
let branch = current_branch(&target)?;
|
||||
let mut args = vec!["worktree", "remove"];
|
||||
if req.force {
|
||||
args.push("--force");
|
||||
}
|
||||
let target_arg = target.to_string_lossy();
|
||||
args.push(&target_arg);
|
||||
let primary_root = primary_worktree_root(&target)?;
|
||||
git::status(&primary_root, &args)?;
|
||||
|
||||
let mut deleted_branch = None;
|
||||
if req.delete_branch
|
||||
&& let Some(branch) = branch
|
||||
{
|
||||
if req.force {
|
||||
git::status(&primary_root, &["branch", "-D", &branch])?;
|
||||
} else {
|
||||
git::status(&primary_root, &["branch", "-d", &branch])?;
|
||||
}
|
||||
deleted_branch = Some(branch);
|
||||
}
|
||||
|
||||
if metadata.location == WorktreeLocation::CodexHome
|
||||
&& let Some(parent) = metadata.worktree_git_root.parent()
|
||||
&& parent.exists()
|
||||
&& parent.read_dir()?.next().is_none()
|
||||
{
|
||||
fs::remove_dir(parent)?;
|
||||
}
|
||||
|
||||
Ok(WorktreeRemoveResult {
|
||||
removed_path: target,
|
||||
deleted_branch,
|
||||
})
|
||||
}
|
||||
|
||||
fn ensure_existing_worktree_matches_branch(
|
||||
worktree_git_root: &Path,
|
||||
metadata: &WorktreeMetadata,
|
||||
requested_branch: &str,
|
||||
) -> Result<()> {
|
||||
if metadata.branch.as_deref() == Some(requested_branch) || metadata.name == requested_branch {
|
||||
return Ok(());
|
||||
}
|
||||
if current_branch(worktree_git_root)?.as_deref() == Some(requested_branch) {
|
||||
return Ok(());
|
||||
}
|
||||
anyhow::bail!(
|
||||
"managed worktree path {} is already used by {}; choose a different branch name",
|
||||
worktree_git_root.display(),
|
||||
metadata.branch.as_deref().unwrap_or(metadata.name.as_str())
|
||||
)
|
||||
}
|
||||
|
||||
fn target_worktree_path(req: &WorktreeRemoveRequest) -> Result<PathBuf> {
|
||||
let raw = PathBuf::from(&req.name_or_path);
|
||||
if raw.is_absolute() {
|
||||
return Ok(raw);
|
||||
}
|
||||
let entries = list_worktrees(WorktreeListQuery {
|
||||
codex_home: req.codex_home.clone(),
|
||||
source_cwd: req.source_cwd.clone(),
|
||||
include_all_repos: req.source_cwd.is_none(),
|
||||
})?;
|
||||
let matches = entries
|
||||
.into_iter()
|
||||
.filter(|entry| {
|
||||
entry.branch.as_deref() == Some(req.name_or_path.as_str())
|
||||
|| entry.name == req.name_or_path
|
||||
|| entry.slug == req.name_or_path
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
match matches.as_slice() {
|
||||
[entry] => Ok(entry.worktree_git_root.clone()),
|
||||
[] => anyhow::bail!("no managed worktree named {}", req.name_or_path),
|
||||
_ => anyhow::bail!(
|
||||
"multiple managed worktrees named {}; pass a path instead",
|
||||
req.name_or_path
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn info_from_existing_worktree(
|
||||
codex_home: &Path,
|
||||
worktree_git_root: &Path,
|
||||
fallback_name: Option<String>,
|
||||
fallback_slug: Option<String>,
|
||||
) -> Result<WorktreeInfo> {
|
||||
let metadata = metadata::read_worktree_metadata(worktree_git_root)?;
|
||||
let root = git::stdout(worktree_git_root, &["rev-parse", "--show-toplevel"])
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| worktree_git_root.to_path_buf());
|
||||
let common_git_dir = git::stdout(worktree_git_root, &["rev-parse", "--git-common-dir"])
|
||||
.map(|value| absolutize(worktree_git_root, Path::new(&value)))
|
||||
.unwrap_or_else(|_| PathBuf::new());
|
||||
let branch = current_branch(worktree_git_root)?;
|
||||
let head = git::stdout(worktree_git_root, &["rev-parse", "HEAD"]).ok();
|
||||
let dirty = dirty::dirty_state(worktree_git_root).unwrap_or_default();
|
||||
let (source, location) = classify_worktree(codex_home, worktree_git_root, metadata.as_ref());
|
||||
let repo_name = root
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "repo".to_string());
|
||||
let id = metadata
|
||||
.as_ref()
|
||||
.map(|metadata| metadata.repo_id.clone())
|
||||
.unwrap_or_else(|| {
|
||||
root.strip_prefix(paths::codex_worktrees_root(codex_home))
|
||||
.ok()
|
||||
.and_then(|path| path.components().next())
|
||||
.map(|component| component.as_os_str().to_string_lossy().to_string())
|
||||
.unwrap_or_default()
|
||||
});
|
||||
let name = metadata
|
||||
.as_ref()
|
||||
.map(|metadata| metadata.name.clone())
|
||||
.or(fallback_name)
|
||||
.or_else(|| branch.clone())
|
||||
.unwrap_or_else(|| repo_name.clone());
|
||||
let slug = metadata
|
||||
.as_ref()
|
||||
.map(|metadata| metadata.slug.clone())
|
||||
.or(fallback_slug)
|
||||
.unwrap_or_else(|| paths::slugify_name(&name).unwrap_or_else(|_| name.clone()));
|
||||
let workspace_cwd = metadata
|
||||
.as_ref()
|
||||
.map(|metadata| metadata.workspace_cwd.clone())
|
||||
.unwrap_or_else(|| root.clone());
|
||||
let original_relative_cwd = metadata
|
||||
.as_ref()
|
||||
.map(|metadata| metadata.original_relative_cwd.clone())
|
||||
.unwrap_or_default();
|
||||
Ok(WorktreeInfo {
|
||||
id,
|
||||
name,
|
||||
slug,
|
||||
source,
|
||||
location,
|
||||
repo_name,
|
||||
repo_root: root,
|
||||
common_git_dir,
|
||||
worktree_git_root: worktree_git_root.to_path_buf(),
|
||||
workspace_cwd,
|
||||
original_relative_cwd,
|
||||
branch,
|
||||
head,
|
||||
owner_thread_id: metadata.and_then(|metadata| metadata.owner_thread_id),
|
||||
metadata_path: metadata_path_for_display(worktree_git_root)?,
|
||||
dirty,
|
||||
})
|
||||
}
|
||||
|
||||
fn classify_worktree(
|
||||
codex_home: &Path,
|
||||
worktree_git_root: &Path,
|
||||
metadata: Option<&WorktreeMetadata>,
|
||||
) -> (WorktreeSource, WorktreeLocation) {
|
||||
if let Some(metadata) = metadata {
|
||||
return (metadata.source, metadata.location);
|
||||
}
|
||||
if paths::is_managed_worktree_path(worktree_git_root, codex_home) {
|
||||
return (WorktreeSource::App, WorktreeLocation::CodexHome);
|
||||
}
|
||||
(WorktreeSource::Git, WorktreeLocation::External)
|
||||
}
|
||||
|
||||
fn display_branch_or_name(info: &WorktreeInfo) -> &str {
|
||||
info.branch.as_deref().unwrap_or(&info.name)
|
||||
}
|
||||
|
||||
struct SourceRepo {
|
||||
root: PathBuf,
|
||||
relative_cwd: PathBuf,
|
||||
common_git_dir: PathBuf,
|
||||
repo_name: String,
|
||||
id: String,
|
||||
}
|
||||
|
||||
impl SourceRepo {
|
||||
fn resolve(source_cwd: &Path) -> Result<Self> {
|
||||
let source_cwd = source_cwd
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| source_cwd.to_path_buf());
|
||||
let root = PathBuf::from(git::stdout(&source_cwd, &["rev-parse", "--show-toplevel"])?);
|
||||
let root = root.canonicalize().unwrap_or(root);
|
||||
let common_git_dir_raw = git::stdout(&source_cwd, &["rev-parse", "--git-common-dir"])?;
|
||||
let common_git_dir = absolutize(&source_cwd, Path::new(&common_git_dir_raw))
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| absolutize(&source_cwd, Path::new(&common_git_dir_raw)));
|
||||
let origin = git::stdout(&root, &["remote", "get-url", "origin"]).ok();
|
||||
let id = paths::repo_fingerprint(&common_git_dir, origin.as_deref());
|
||||
let repo_name = root
|
||||
.file_name()
|
||||
.context("repository root has no directory name")?
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let relative_cwd = source_cwd
|
||||
.strip_prefix(&root)
|
||||
.unwrap_or_else(|_| Path::new(""))
|
||||
.to_path_buf();
|
||||
Ok(Self {
|
||||
root,
|
||||
relative_cwd,
|
||||
common_git_dir,
|
||||
repo_name,
|
||||
id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn branch_checkout_path(root: &Path, branch: &str) -> Result<Option<PathBuf>> {
|
||||
let worktrees = parse_worktree_list(&git::stdout(root, &["worktree", "list", "--porcelain"])?);
|
||||
Ok(worktrees
|
||||
.into_iter()
|
||||
.find_map(|entry| (entry.branch.as_deref() == Some(branch)).then_some(entry.path)))
|
||||
}
|
||||
|
||||
fn branch_exists(root: &Path, branch: &str) -> bool {
|
||||
git::status(
|
||||
root,
|
||||
&[
|
||||
"show-ref",
|
||||
"--verify",
|
||||
"--quiet",
|
||||
&format!("refs/heads/{branch}"),
|
||||
],
|
||||
)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
fn ensure_safe_branch_name(root: &Path, branch: &str) -> Result<()> {
|
||||
if branch.trim().is_empty() {
|
||||
anyhow::bail!("branch name must not be empty");
|
||||
}
|
||||
git::status(root, &["check-ref-format", "--branch", branch]).context("invalid branch name")
|
||||
}
|
||||
|
||||
fn current_branch(root: &Path) -> Result<Option<String>> {
|
||||
let output = std::process::Command::new("git")
|
||||
.args(["symbolic-ref", "--quiet", "--short", "HEAD"])
|
||||
.current_dir(root)
|
||||
.output()?;
|
||||
if output.status.success() {
|
||||
let branch = String::from_utf8(output.stdout)?.trim().to_string();
|
||||
Ok((!branch.is_empty()).then_some(branch))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn primary_worktree_root(root: &Path) -> Result<PathBuf> {
|
||||
let worktrees = parse_worktree_list(&git::stdout(root, &["worktree", "list", "--porcelain"])?);
|
||||
worktrees
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|entry| entry.path)
|
||||
.context("git did not report a primary worktree")
|
||||
}
|
||||
|
||||
fn metadata_path_for_display(worktree_path: &Path) -> Result<PathBuf> {
|
||||
let path = git::stdout(
|
||||
worktree_path,
|
||||
&["rev-parse", "--git-path", "codex-worktree.json"],
|
||||
)?;
|
||||
Ok(absolutize(worktree_path, Path::new(&path)))
|
||||
}
|
||||
|
||||
fn absolutize(cwd: &Path, path: &Path) -> PathBuf {
|
||||
if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
cwd.join(path)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct GitWorktreeEntry {
|
||||
path: PathBuf,
|
||||
branch: Option<String>,
|
||||
}
|
||||
|
||||
fn parse_worktree_list(output: &str) -> Vec<GitWorktreeEntry> {
|
||||
let mut entries = Vec::new();
|
||||
let mut path = None;
|
||||
let mut branch = None;
|
||||
for line in output.lines().chain(std::iter::once("")) {
|
||||
if line.is_empty() {
|
||||
if let Some(path) = path.take() {
|
||||
entries.push(GitWorktreeEntry {
|
||||
path,
|
||||
branch: branch.take(),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(raw_path) = line.strip_prefix("worktree ") {
|
||||
path = Some(PathBuf::from(raw_path));
|
||||
} else if let Some(raw_branch) = line.strip_prefix("branch ") {
|
||||
branch = Some(raw_branch.trim_start_matches("refs/heads/").to_string());
|
||||
}
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn parse_worktree_list_preserves_branches() {
|
||||
let entries = parse_worktree_list(
|
||||
"worktree /repo\nHEAD abc\nbranch refs/heads/main\n\nworktree /repo.wt\nHEAD def\nbranch refs/heads/codex/demo\n\n",
|
||||
);
|
||||
assert_eq!(
|
||||
entries,
|
||||
vec![
|
||||
GitWorktreeEntry {
|
||||
path: PathBuf::from("/repo"),
|
||||
branch: Some("main".to_string())
|
||||
},
|
||||
GitWorktreeEntry {
|
||||
path: PathBuf::from("/repo.wt"),
|
||||
branch: Some("codex/demo".to_string())
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::time::SystemTime;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::WorktreeInfo;
|
||||
use crate::WorktreeLocation;
|
||||
use crate::WorktreeSource;
|
||||
use crate::git;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WorktreeThreadMetadata {
|
||||
pub version: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub owner_thread_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WorktreeMetadata {
|
||||
pub version: u32,
|
||||
pub manager: String,
|
||||
pub backend: String,
|
||||
#[serde(default = "default_source")]
|
||||
pub source: WorktreeSource,
|
||||
#[serde(default = "default_location")]
|
||||
pub location: WorktreeLocation,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub slug: String,
|
||||
pub branch: Option<String>,
|
||||
pub repo_id: String,
|
||||
pub repo_name: String,
|
||||
pub source_repo_root: PathBuf,
|
||||
pub original_relative_cwd: PathBuf,
|
||||
pub worktree_git_root: PathBuf,
|
||||
pub workspace_cwd: PathBuf,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
pub owner_thread_id: Option<String>,
|
||||
pub tmux_session: Option<String>,
|
||||
}
|
||||
|
||||
impl WorktreeMetadata {
|
||||
pub fn from_info(info: &WorktreeInfo, source_repo_root: PathBuf) -> Self {
|
||||
let now = unix_seconds();
|
||||
Self {
|
||||
version: 1,
|
||||
manager: "codex-cli".to_string(),
|
||||
backend: "git".to_string(),
|
||||
source: info.source,
|
||||
location: info.location,
|
||||
id: info.id.clone(),
|
||||
name: info.name.clone(),
|
||||
slug: info.slug.clone(),
|
||||
branch: info.branch.clone(),
|
||||
repo_id: info.id.clone(),
|
||||
repo_name: info.repo_name.clone(),
|
||||
source_repo_root,
|
||||
original_relative_cwd: info.original_relative_cwd.clone(),
|
||||
worktree_git_root: info.worktree_git_root.clone(),
|
||||
workspace_cwd: info.workspace_cwd.clone(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
owner_thread_id: info.owner_thread_id.clone(),
|
||||
tmux_session: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_source() -> WorktreeSource {
|
||||
WorktreeSource::Legacy
|
||||
}
|
||||
|
||||
fn default_location() -> WorktreeLocation {
|
||||
WorktreeLocation::CodexHome
|
||||
}
|
||||
|
||||
pub fn read_worktree_metadata(worktree_path: &Path) -> Result<Option<WorktreeMetadata>> {
|
||||
let path = metadata_path(worktree_path, "codex-worktree.json")?;
|
||||
read_json_if_exists(&path)
|
||||
}
|
||||
|
||||
pub fn write_worktree_metadata(worktree_path: &Path, metadata: &WorktreeMetadata) -> Result<()> {
|
||||
let path = metadata_path(worktree_path, "codex-worktree.json")?;
|
||||
write_json(&path, metadata)
|
||||
}
|
||||
|
||||
pub fn bind_thread(workspace_cwd: &Path, thread_id: &str) -> Result<()> {
|
||||
let git_root = git::stdout(workspace_cwd, &["rev-parse", "--show-toplevel"])?;
|
||||
let git_root = PathBuf::from(git_root);
|
||||
let owner = WorktreeThreadMetadata {
|
||||
version: 1,
|
||||
owner_thread_id: Some(thread_id.to_string()),
|
||||
};
|
||||
let owner_path = metadata_path(&git_root, "codex-thread.json")?;
|
||||
write_json(&owner_path, &owner)?;
|
||||
|
||||
if let Some(mut metadata) = read_worktree_metadata(&git_root)? {
|
||||
metadata.owner_thread_id = Some(thread_id.to_string());
|
||||
metadata.updated_at = unix_seconds();
|
||||
write_worktree_metadata(&git_root, &metadata)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn write_pending_owner_metadata(worktree_path: &Path) -> Result<()> {
|
||||
let metadata = WorktreeThreadMetadata {
|
||||
version: 1,
|
||||
owner_thread_id: None,
|
||||
};
|
||||
let path = metadata_path(worktree_path, "codex-thread.json")?;
|
||||
write_json(&path, &metadata)
|
||||
}
|
||||
|
||||
fn read_json_if_exists<T>(path: &Path) -> Result<Option<T>>
|
||||
where
|
||||
T: serde::de::DeserializeOwned,
|
||||
{
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let contents = fs::read_to_string(path)?;
|
||||
Ok(Some(serde_json::from_str(&contents)?))
|
||||
}
|
||||
|
||||
fn write_json<T>(path: &Path, value: &T) -> Result<()>
|
||||
where
|
||||
T: serde::Serialize,
|
||||
{
|
||||
let contents = serde_json::to_string_pretty(value)?;
|
||||
fs::write(path, contents)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn metadata_path(worktree_path: &Path, name: &str) -> Result<PathBuf> {
|
||||
let path = git::stdout(worktree_path, &["rev-parse", "--git-path", name])?;
|
||||
let path = PathBuf::from(path);
|
||||
Ok(if path.is_absolute() {
|
||||
path
|
||||
} else {
|
||||
worktree_path.join(path)
|
||||
})
|
||||
}
|
||||
|
||||
fn unix_seconds() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs() as i64)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use sha2::Digest;
|
||||
|
||||
pub fn codex_worktrees_root(codex_home: &Path) -> PathBuf {
|
||||
codex_home.join("worktrees")
|
||||
}
|
||||
|
||||
pub fn is_managed_worktree_path(path: &Path, codex_home: &Path) -> bool {
|
||||
let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
|
||||
let root = codex_worktrees_root(codex_home)
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| codex_worktrees_root(codex_home));
|
||||
path.starts_with(root)
|
||||
}
|
||||
|
||||
pub fn slugify_name(name: &str) -> Result<String> {
|
||||
let slug = name
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
ch.to_ascii_lowercase()
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect::<String>()
|
||||
.split('-')
|
||||
.filter(|part| !part.is_empty())
|
||||
.take(12)
|
||||
.collect::<Vec<_>>()
|
||||
.join("-");
|
||||
if slug.is_empty() {
|
||||
anyhow::bail!("worktree name must contain at least one ASCII letter or digit");
|
||||
}
|
||||
Ok(slug)
|
||||
}
|
||||
|
||||
pub fn sanitize_branch_for_path(branch: &str) -> Result<String> {
|
||||
let sanitized = branch.replace(['/', '\\'], "-");
|
||||
if sanitized.trim().is_empty() {
|
||||
anyhow::bail!("branch name must produce a non-empty worktree path segment");
|
||||
}
|
||||
Ok(sanitized)
|
||||
}
|
||||
|
||||
pub fn repo_fingerprint(common_git_dir: &Path, origin_url: Option<&str>) -> String {
|
||||
let mut hasher = sha2::Sha256::new();
|
||||
hasher.update(common_git_dir.to_string_lossy().as_bytes());
|
||||
if let Some(origin_url) = origin_url {
|
||||
hasher.update(b"\0");
|
||||
hasher.update(origin_url.as_bytes());
|
||||
}
|
||||
let digest = hasher.finalize();
|
||||
digest
|
||||
.iter()
|
||||
.take(8)
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn sibling_worktree_git_root(repo_root: &Path, branch: &str) -> Result<PathBuf> {
|
||||
let repo_name = repo_root
|
||||
.file_name()
|
||||
.context("source repository root has no directory name")?;
|
||||
let parent = repo_root
|
||||
.parent()
|
||||
.context("source repository root has no parent directory")?;
|
||||
let sanitized_branch = sanitize_branch_for_path(branch)?;
|
||||
let dirname = format!("{}.{}", repo_name.to_string_lossy(), sanitized_branch);
|
||||
Ok(parent.join(dirname))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn slugify_name_keeps_short_ascii_slug() -> Result<()> {
|
||||
assert_eq!(slugify_name("Fix parser tests!")?, "fix-parser-tests");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_branch_for_path_matches_worktrunk_style() -> Result<()> {
|
||||
assert_eq!(
|
||||
sanitize_branch_for_path("feature/auth\\windows")?,
|
||||
"feature-auth-windows"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sibling_worktree_path_matches_worktrunk_default() -> Result<()> {
|
||||
assert_eq!(
|
||||
sibling_worktree_git_root(Path::new("/Users/me/code/codex"), "feature/auth")?,
|
||||
PathBuf::from("/Users/me/code/codex.feature-auth")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user