Add agent worktree directory setting + worktree info persistence (#49139)
Add `agent_worktree_directory` to `GitSettings` for configuring where agent worktrees are stored (default: Zed data dir). Remove `Copy` derive from `GitSettings`/`GitContentSettings` (incompatible with String field) and fix downstream `.as_ref().unwrap()` call sites. Define `AgentGitWorktreeInfo` (branch, worktree_path, base_ref) and add it to `DbThread` + `DbThreadMetadata` for persistence and session list display. Closes AI-33 Release Notes: - N/A
This commit is contained in:
1 parent
fc80cd4c25
commit
e8a72de57f
16 files changed
+860
-391
No files matched your search
@@ -1487,6 +1487,25 @@
|
||||
// Should the name or path be displayed first in the git view.
|
||||
// "path_style": "file_name_first" or "file_path_first"
|
||||
"path_style": "file_name_first",
|
||||
// Directory where git worktrees are created, relative to the repository
|
||||
// working directory.
|
||||
//
|
||||
// When the resolved directory is outside the project root, the
|
||||
// project's directory name is automatically appended so that
|
||||
// sibling repos don't collide. For example, with the default
|
||||
// "../worktrees" and a project at ~/code/zed, worktrees are
|
||||
// created under ~/code/worktrees/zed/.
|
||||
//
|
||||
// When the resolved directory is inside the project root, no
|
||||
// extra component is added (it's already project-scoped).
|
||||
//
|
||||
// Examples:
|
||||
// "../worktrees" — ~/code/worktrees/<project>/ (default)
|
||||
// ".git/zed-worktrees" — <project>/.git/zed-worktrees/
|
||||
// "my-worktrees" — <project>/my-worktrees/
|
||||
//
|
||||
// Trailing slashes are ignored.
|
||||
"worktree_directory": "../worktrees",
|
||||
},
|
||||
// The list of custom Git hosting providers.
|
||||
"git_hosting_providers": [
|
||||
|
||||
+136
-6
@@ -23,6 +23,17 @@ pub type DbMessage = crate::Message;
|
||||
pub type DbSummary = crate::legacy_thread::DetailedSummaryState;
|
||||
pub type DbLanguageModel = crate::legacy_thread::SerializedLanguageModel;
|
||||
|
||||
/// Metadata about the git worktree associated with an agent thread.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentGitWorktreeInfo {
|
||||
/// The branch name in the git worktree.
|
||||
pub branch: String,
|
||||
/// Absolute path to the git worktree on disk.
|
||||
pub worktree_path: std::path::PathBuf,
|
||||
/// The base branch/commit the worktree was created from.
|
||||
pub base_ref: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DbThreadMetadata {
|
||||
pub id: acp::SessionId,
|
||||
@@ -30,6 +41,10 @@ pub struct DbThreadMetadata {
|
||||
#[serde(alias = "summary")]
|
||||
pub title: SharedString,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
/// Denormalized from `DbThread::git_worktree_info.branch` for efficient
|
||||
/// listing without decompressing thread data. The blob is the source of
|
||||
/// truth; this column is populated on save for query convenience.
|
||||
pub worktree_branch: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
@@ -53,6 +68,8 @@ pub struct DbThread {
|
||||
pub imported: bool,
|
||||
#[serde(default)]
|
||||
pub subagent_context: Option<crate::SubagentContext>,
|
||||
#[serde(default)]
|
||||
pub git_worktree_info: Option<AgentGitWorktreeInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -91,6 +108,7 @@ impl SharedThread {
|
||||
profile: None,
|
||||
imported: true,
|
||||
subagent_context: None,
|
||||
git_worktree_info: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,6 +283,7 @@ impl DbThread {
|
||||
profile: thread.profile,
|
||||
imported: false,
|
||||
subagent_context: None,
|
||||
git_worktree_info: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -369,6 +388,13 @@ impl ThreadsDatabase {
|
||||
s().ok();
|
||||
}
|
||||
|
||||
if let Ok(mut s) = connection.exec(indoc! {"
|
||||
ALTER TABLE threads ADD COLUMN worktree_branch TEXT
|
||||
"})
|
||||
{
|
||||
s().ok();
|
||||
}
|
||||
|
||||
let db = Self {
|
||||
executor,
|
||||
connection: Arc::new(Mutex::new(connection)),
|
||||
@@ -397,6 +423,10 @@ impl ThreadsDatabase {
|
||||
.subagent_context
|
||||
.as_ref()
|
||||
.map(|ctx| ctx.parent_thread_id.0.clone());
|
||||
let worktree_branch = thread
|
||||
.git_worktree_info
|
||||
.as_ref()
|
||||
.map(|info| info.branch.clone());
|
||||
let json_data = serde_json::to_string(&SerializedThread {
|
||||
thread,
|
||||
version: DbThread::VERSION,
|
||||
@@ -408,11 +438,19 @@ impl ThreadsDatabase {
|
||||
let data_type = DataType::Zstd;
|
||||
let data = compressed;
|
||||
|
||||
let mut insert = connection.exec_bound::<(Arc<str>, Option<Arc<str>>, String, String, DataType, Vec<u8>)>(indoc! {"
|
||||
INSERT OR REPLACE INTO threads (id, parent_id, summary, updated_at, data_type, data) VALUES (?, ?, ?, ?, ?, ?)
|
||||
let mut insert = connection.exec_bound::<(Arc<str>, Option<Arc<str>>, Option<String>, String, String, DataType, Vec<u8>)>(indoc! {"
|
||||
INSERT OR REPLACE INTO threads (id, parent_id, worktree_branch, summary, updated_at, data_type, data) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
"})?;
|
||||
|
||||
insert((id.0, parent_id, title, updated_at, data_type, data))?;
|
||||
insert((
|
||||
id.0,
|
||||
parent_id,
|
||||
worktree_branch,
|
||||
title,
|
||||
updated_at,
|
||||
data_type,
|
||||
data,
|
||||
))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -424,19 +462,20 @@ impl ThreadsDatabase {
|
||||
let connection = connection.lock();
|
||||
|
||||
let mut select = connection
|
||||
.select_bound::<(), (Arc<str>, Option<Arc<str>>, String, String)>(indoc! {"
|
||||
SELECT id, parent_id, summary, updated_at FROM threads ORDER BY updated_at DESC
|
||||
.select_bound::<(), (Arc<str>, Option<Arc<str>>, Option<String>, String, String)>(indoc! {"
|
||||
SELECT id, parent_id, worktree_branch, summary, updated_at FROM threads ORDER BY updated_at DESC
|
||||
"})?;
|
||||
|
||||
let rows = select(())?;
|
||||
let mut threads = Vec::new();
|
||||
|
||||
for (id, parent_id, summary, updated_at) in rows {
|
||||
for (id, parent_id, worktree_branch, summary, updated_at) in rows {
|
||||
threads.push(DbThreadMetadata {
|
||||
id: acp::SessionId::new(id),
|
||||
parent_session_id: parent_id.map(acp::SessionId::new),
|
||||
title: summary.into(),
|
||||
updated_at: DateTime::parse_from_rfc3339(&updated_at)?.with_timezone(&Utc),
|
||||
worktree_branch,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -570,6 +609,7 @@ mod tests {
|
||||
profile: None,
|
||||
imported: false,
|
||||
subagent_context: None,
|
||||
git_worktree_info: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -713,4 +753,94 @@ mod tests {
|
||||
"Regular threads should have no subagent_context"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_git_worktree_info_roundtrip(cx: &mut TestAppContext) {
|
||||
let database = ThreadsDatabase::new(cx.executor()).unwrap();
|
||||
|
||||
let thread_id = session_id("worktree-thread");
|
||||
let mut thread = make_thread(
|
||||
"Worktree Thread",
|
||||
Utc.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap(),
|
||||
);
|
||||
thread.git_worktree_info = Some(AgentGitWorktreeInfo {
|
||||
branch: "zed/agent/a4Xiu".to_string(),
|
||||
worktree_path: std::path::PathBuf::from("/repo/worktrees/zed/agent/a4Xiu"),
|
||||
base_ref: "main".to_string(),
|
||||
});
|
||||
|
||||
database
|
||||
.save_thread(thread_id.clone(), thread)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let loaded = database
|
||||
.load_thread(thread_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("thread should exist");
|
||||
|
||||
let info = loaded
|
||||
.git_worktree_info
|
||||
.expect("git_worktree_info should be restored");
|
||||
assert_eq!(info.branch, "zed/agent/a4Xiu");
|
||||
assert_eq!(
|
||||
info.worktree_path,
|
||||
std::path::PathBuf::from("/repo/worktrees/zed/agent/a4Xiu")
|
||||
);
|
||||
assert_eq!(info.base_ref, "main");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_session_list_includes_worktree_meta(cx: &mut TestAppContext) {
|
||||
let database = ThreadsDatabase::new(cx.executor()).unwrap();
|
||||
|
||||
// Save a thread with worktree info
|
||||
let worktree_id = session_id("wt-thread");
|
||||
let mut worktree_thread = make_thread(
|
||||
"With Worktree",
|
||||
Utc.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap(),
|
||||
);
|
||||
worktree_thread.git_worktree_info = Some(AgentGitWorktreeInfo {
|
||||
branch: "zed/agent/bR9kz".to_string(),
|
||||
worktree_path: std::path::PathBuf::from("/repo/worktrees/zed/agent/bR9kz"),
|
||||
base_ref: "develop".to_string(),
|
||||
});
|
||||
|
||||
database
|
||||
.save_thread(worktree_id.clone(), worktree_thread)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Save a thread without worktree info
|
||||
let plain_id = session_id("plain-thread");
|
||||
let plain_thread = make_thread(
|
||||
"Without Worktree",
|
||||
Utc.with_ymd_and_hms(2024, 6, 15, 11, 0, 0).unwrap(),
|
||||
);
|
||||
|
||||
database
|
||||
.save_thread(plain_id.clone(), plain_thread)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// List threads and verify worktree_branch is populated correctly
|
||||
let threads = database.list_threads().await.unwrap();
|
||||
assert_eq!(threads.len(), 2);
|
||||
|
||||
let wt_entry = threads
|
||||
.iter()
|
||||
.find(|t| t.id == worktree_id)
|
||||
.expect("should find worktree thread");
|
||||
assert_eq!(wt_entry.worktree_branch.as_deref(), Some("zed/agent/bR9kz"));
|
||||
|
||||
let plain_entry = threads
|
||||
.iter()
|
||||
.find(|t| t.id == plain_id)
|
||||
.expect("should find plain thread");
|
||||
assert!(
|
||||
plain_entry.worktree_branch.is_none(),
|
||||
"plain thread should have no worktree_branch"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
use crate::{
|
||||
ContextServerRegistry, CopyPathTool, CreateDirectoryTool, DbLanguageModel, DbThread,
|
||||
DeletePathTool, DiagnosticsTool, EditFileTool, FetchTool, FindPathTool, GrepTool,
|
||||
ListDirectoryTool, MovePathTool, NowTool, OpenTool, ProjectSnapshot, ReadFileTool,
|
||||
RestoreFileFromDiskTool, SaveFileTool, StreamingEditFileTool, SubagentTool,
|
||||
AgentGitWorktreeInfo, ContextServerRegistry, CopyPathTool, CreateDirectoryTool,
|
||||
DbLanguageModel, DbThread, DeletePathTool, DiagnosticsTool, EditFileTool, FetchTool,
|
||||
FindPathTool, GrepTool, ListDirectoryTool, MovePathTool, NowTool, OpenTool, ProjectSnapshot,
|
||||
ReadFileTool, RestoreFileFromDiskTool, SaveFileTool, StreamingEditFileTool, SubagentTool,
|
||||
SystemPromptTemplate, Template, Templates, TerminalTool, ToolPermissionDecision, WebSearchTool,
|
||||
decide_permission_from_settings,
|
||||
};
|
||||
@@ -891,6 +891,8 @@ pub struct Thread {
|
||||
subagent_context: Option<SubagentContext>,
|
||||
/// Weak references to running subagent threads for cancellation propagation
|
||||
running_subagents: Vec<WeakEntity<Thread>>,
|
||||
/// Git worktree info if this thread is running in an agent worktree.
|
||||
git_worktree_info: Option<AgentGitWorktreeInfo>,
|
||||
}
|
||||
|
||||
impl Thread {
|
||||
@@ -981,6 +983,7 @@ impl Thread {
|
||||
imported: false,
|
||||
subagent_context: None,
|
||||
running_subagents: Vec::new(),
|
||||
git_worktree_info: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1205,6 +1208,7 @@ impl Thread {
|
||||
imported: db_thread.imported,
|
||||
subagent_context: db_thread.subagent_context,
|
||||
running_subagents: Vec::new(),
|
||||
git_worktree_info: db_thread.git_worktree_info,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1225,6 +1229,7 @@ impl Thread {
|
||||
profile: Some(self.profile_id.clone()),
|
||||
imported: self.imported,
|
||||
subagent_context: self.subagent_context.clone(),
|
||||
git_worktree_info: self.git_worktree_info.clone(),
|
||||
};
|
||||
|
||||
cx.background_spawn(async move {
|
||||
|
||||
@@ -162,6 +162,7 @@ mod tests {
|
||||
profile: None,
|
||||
imported: false,
|
||||
subagent_context: None,
|
||||
git_worktree_info: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -221,7 +221,13 @@ impl Settings for EditorSettings {
|
||||
scrollbar: Scrollbar {
|
||||
show: scrollbar.show.map(Into::into).unwrap(),
|
||||
git_diff: scrollbar.git_diff.unwrap()
|
||||
&& content.git.unwrap().enabled.unwrap().is_git_diff_enabled(),
|
||||
&& content
|
||||
.git
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.enabled
|
||||
.unwrap()
|
||||
.is_git_diff_enabled(),
|
||||
selected_text: scrollbar.selected_text.unwrap(),
|
||||
selected_symbol: scrollbar.selected_symbol.unwrap(),
|
||||
search_results: scrollbar.search_results.unwrap(),
|
||||
|
||||
+116
-107
@@ -893,125 +893,134 @@ mod tests {
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_fake_worktree_lifecycle(cx: &mut TestAppContext) {
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
fs.insert_tree("/project", json!({".git": {}, "file.txt": "content"}))
|
||||
.await;
|
||||
let repo = fs
|
||||
.open_repo(Path::new("/project/.git"), None)
|
||||
.expect("should open fake repo");
|
||||
let worktree_dir_settings = &["../worktrees", ".git/zed-worktrees", "my-worktrees/"];
|
||||
|
||||
// Initially no worktrees
|
||||
let worktrees = repo.worktrees().await.unwrap();
|
||||
assert!(worktrees.is_empty());
|
||||
for worktree_dir_setting in worktree_dir_settings {
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
fs.insert_tree("/project", json!({".git": {}, "file.txt": "content"}))
|
||||
.await;
|
||||
let repo = fs
|
||||
.open_repo(Path::new("/project/.git"), None)
|
||||
.expect("should open fake repo");
|
||||
|
||||
// Create a worktree
|
||||
repo.create_worktree(
|
||||
"feature-branch".to_string(),
|
||||
PathBuf::from("/worktrees"),
|
||||
Some("abc123".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// Initially no worktrees
|
||||
let worktrees = repo.worktrees().await.unwrap();
|
||||
assert!(worktrees.is_empty());
|
||||
|
||||
// List worktrees — should have one
|
||||
let worktrees = repo.worktrees().await.unwrap();
|
||||
assert_eq!(worktrees.len(), 1);
|
||||
assert_eq!(worktrees[0].path, Path::new("/worktrees/feature-branch"));
|
||||
assert_eq!(worktrees[0].ref_name.as_ref(), "refs/heads/feature-branch");
|
||||
assert_eq!(worktrees[0].sha.as_ref(), "abc123");
|
||||
let expected_dir = git::repository::resolve_worktree_directory(
|
||||
Path::new("/project"),
|
||||
worktree_dir_setting,
|
||||
);
|
||||
|
||||
// Directory should exist in FakeFs after create
|
||||
assert!(
|
||||
fs.is_dir(Path::new("/worktrees/feature-branch")).await,
|
||||
"worktree directory should be created in FakeFs"
|
||||
);
|
||||
|
||||
// Create a second worktree (without explicit commit)
|
||||
repo.create_worktree(
|
||||
"bugfix-branch".to_string(),
|
||||
PathBuf::from("/worktrees"),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let worktrees = repo.worktrees().await.unwrap();
|
||||
assert_eq!(worktrees.len(), 2);
|
||||
assert!(
|
||||
fs.is_dir(Path::new("/worktrees/bugfix-branch")).await,
|
||||
"second worktree directory should be created in FakeFs"
|
||||
);
|
||||
|
||||
// Rename the first worktree
|
||||
repo.rename_worktree(
|
||||
PathBuf::from("/worktrees/feature-branch"),
|
||||
PathBuf::from("/worktrees/renamed-branch"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let worktrees = repo.worktrees().await.unwrap();
|
||||
assert_eq!(worktrees.len(), 2);
|
||||
assert!(
|
||||
worktrees
|
||||
.iter()
|
||||
.any(|w| w.path == Path::new("/worktrees/renamed-branch")),
|
||||
"renamed worktree should exist at new path"
|
||||
);
|
||||
assert!(
|
||||
worktrees
|
||||
.iter()
|
||||
.all(|w| w.path != Path::new("/worktrees/feature-branch")),
|
||||
"old path should no longer exist"
|
||||
);
|
||||
|
||||
// Directory should be moved in FakeFs after rename
|
||||
assert!(
|
||||
!fs.is_dir(Path::new("/worktrees/feature-branch")).await,
|
||||
"old worktree directory should not exist after rename"
|
||||
);
|
||||
assert!(
|
||||
fs.is_dir(Path::new("/worktrees/renamed-branch")).await,
|
||||
"new worktree directory should exist after rename"
|
||||
);
|
||||
|
||||
// Rename a nonexistent worktree should fail
|
||||
let result = repo
|
||||
.rename_worktree(PathBuf::from("/nonexistent"), PathBuf::from("/somewhere"))
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
|
||||
// Remove a worktree
|
||||
repo.remove_worktree(PathBuf::from("/worktrees/renamed-branch"), false)
|
||||
// Create a worktree
|
||||
repo.create_worktree(
|
||||
"feature-branch".to_string(),
|
||||
expected_dir.clone(),
|
||||
Some("abc123".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let worktrees = repo.worktrees().await.unwrap();
|
||||
assert_eq!(worktrees.len(), 1);
|
||||
assert_eq!(worktrees[0].path, Path::new("/worktrees/bugfix-branch"));
|
||||
// List worktrees — should have one
|
||||
let worktrees = repo.worktrees().await.unwrap();
|
||||
assert_eq!(worktrees.len(), 1);
|
||||
assert_eq!(
|
||||
worktrees[0].path,
|
||||
expected_dir.join("feature-branch"),
|
||||
"failed for worktree_directory setting: {worktree_dir_setting:?}"
|
||||
);
|
||||
assert_eq!(worktrees[0].ref_name.as_ref(), "refs/heads/feature-branch");
|
||||
assert_eq!(worktrees[0].sha.as_ref(), "abc123");
|
||||
|
||||
// Directory should be removed from FakeFs after remove
|
||||
assert!(
|
||||
!fs.is_dir(Path::new("/worktrees/renamed-branch")).await,
|
||||
"worktree directory should be removed from FakeFs"
|
||||
);
|
||||
// Directory should exist in FakeFs after create
|
||||
assert!(
|
||||
fs.is_dir(&expected_dir.join("feature-branch")).await,
|
||||
"worktree directory should be created in FakeFs for setting {worktree_dir_setting:?}"
|
||||
);
|
||||
|
||||
// Remove a nonexistent worktree should fail
|
||||
let result = repo
|
||||
.remove_worktree(PathBuf::from("/nonexistent"), false)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
// Create a second worktree (without explicit commit)
|
||||
repo.create_worktree("bugfix-branch".to_string(), expected_dir.clone(), None)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Remove the last worktree
|
||||
repo.remove_worktree(PathBuf::from("/worktrees/bugfix-branch"), false)
|
||||
let worktrees = repo.worktrees().await.unwrap();
|
||||
assert_eq!(worktrees.len(), 2);
|
||||
assert!(
|
||||
fs.is_dir(&expected_dir.join("bugfix-branch")).await,
|
||||
"second worktree directory should be created in FakeFs for setting {worktree_dir_setting:?}"
|
||||
);
|
||||
|
||||
// Rename the first worktree
|
||||
repo.rename_worktree(
|
||||
expected_dir.join("feature-branch"),
|
||||
expected_dir.join("renamed-branch"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let worktrees = repo.worktrees().await.unwrap();
|
||||
assert!(worktrees.is_empty());
|
||||
assert!(
|
||||
!fs.is_dir(Path::new("/worktrees/bugfix-branch")).await,
|
||||
"last worktree directory should be removed from FakeFs"
|
||||
);
|
||||
let worktrees = repo.worktrees().await.unwrap();
|
||||
assert_eq!(worktrees.len(), 2);
|
||||
assert!(
|
||||
worktrees
|
||||
.iter()
|
||||
.any(|w| w.path == expected_dir.join("renamed-branch")),
|
||||
"renamed worktree should exist at new path for setting {worktree_dir_setting:?}"
|
||||
);
|
||||
assert!(
|
||||
worktrees
|
||||
.iter()
|
||||
.all(|w| w.path != expected_dir.join("feature-branch")),
|
||||
"old path should no longer exist for setting {worktree_dir_setting:?}"
|
||||
);
|
||||
|
||||
// Directory should be moved in FakeFs after rename
|
||||
assert!(
|
||||
!fs.is_dir(&expected_dir.join("feature-branch")).await,
|
||||
"old worktree directory should not exist after rename for setting {worktree_dir_setting:?}"
|
||||
);
|
||||
assert!(
|
||||
fs.is_dir(&expected_dir.join("renamed-branch")).await,
|
||||
"new worktree directory should exist after rename for setting {worktree_dir_setting:?}"
|
||||
);
|
||||
|
||||
// Rename a nonexistent worktree should fail
|
||||
let result = repo
|
||||
.rename_worktree(PathBuf::from("/nonexistent"), PathBuf::from("/somewhere"))
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
|
||||
// Remove a worktree
|
||||
repo.remove_worktree(expected_dir.join("renamed-branch"), false)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let worktrees = repo.worktrees().await.unwrap();
|
||||
assert_eq!(worktrees.len(), 1);
|
||||
assert_eq!(worktrees[0].path, expected_dir.join("bugfix-branch"));
|
||||
|
||||
// Directory should be removed from FakeFs after remove
|
||||
assert!(
|
||||
!fs.is_dir(&expected_dir.join("renamed-branch")).await,
|
||||
"worktree directory should be removed from FakeFs for setting {worktree_dir_setting:?}"
|
||||
);
|
||||
|
||||
// Remove a nonexistent worktree should fail
|
||||
let result = repo
|
||||
.remove_worktree(PathBuf::from("/nonexistent"), false)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
|
||||
// Remove the last worktree
|
||||
repo.remove_worktree(expected_dir.join("bugfix-branch"), false)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let worktrees = repo.worktrees().await.unwrap();
|
||||
assert!(worktrees.is_empty());
|
||||
assert!(
|
||||
!fs.is_dir(&expected_dir.join("bugfix-branch")).await,
|
||||
"last worktree directory should be removed from FakeFs for setting {worktree_dir_setting:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-25
@@ -33,9 +33,11 @@ use is_executable::IsExecutable;
|
||||
use rope::Rope;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use smol::io::AsyncWriteExt;
|
||||
#[cfg(any(target_os = "windows", feature = "test-support"))]
|
||||
use std::path::Component;
|
||||
use std::{
|
||||
io::{self, Write},
|
||||
path::{Component, Path, PathBuf},
|
||||
path::{Path, PathBuf},
|
||||
pin::Pin,
|
||||
sync::Arc,
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
@@ -2813,30 +2815,7 @@ impl Fs for FakeFs {
|
||||
}
|
||||
|
||||
pub fn normalize_path(path: &Path) -> PathBuf {
|
||||
let mut components = path.components().peekable();
|
||||
let mut ret = if let Some(c @ Component::Prefix(..)) = components.peek().cloned() {
|
||||
components.next();
|
||||
PathBuf::from(c.as_os_str())
|
||||
} else {
|
||||
PathBuf::new()
|
||||
};
|
||||
|
||||
for component in components {
|
||||
match component {
|
||||
Component::Prefix(..) => unreachable!(),
|
||||
Component::RootDir => {
|
||||
ret.push(component.as_os_str());
|
||||
}
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir => {
|
||||
ret.pop();
|
||||
}
|
||||
Component::Normal(c) => {
|
||||
ret.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
ret
|
||||
util::normalize_path(path)
|
||||
}
|
||||
|
||||
pub async fn copy_recursive<'a>(
|
||||
|
||||
+473
-206
@@ -21,6 +21,7 @@ use text::LineEnding;
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::ffi::{OsStr, OsString};
|
||||
|
||||
use std::process::ExitStatus;
|
||||
use std::str::FromStr;
|
||||
use std::{
|
||||
@@ -34,7 +35,7 @@ use thiserror::Error;
|
||||
use util::command::{Stdio, new_command};
|
||||
use util::paths::PathStyle;
|
||||
use util::rel_path::RelPath;
|
||||
use util::{ResultExt, paths};
|
||||
use util::{ResultExt, normalize_path, paths};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub use askpass::{AskPassDelegate, AskPassResult, AskPassSession};
|
||||
@@ -51,6 +52,100 @@ static GRAPH_COMMIT_FORMAT: &str = "--format=%H%x00%P%x00%D";
|
||||
/// Number of commits to load per chunk for the git graph.
|
||||
pub const GRAPH_CHUNK_SIZE: usize = 1000;
|
||||
|
||||
/// Default value for the `git.worktree_directory` setting.
|
||||
pub const DEFAULT_WORKTREE_DIRECTORY: &str = "../worktrees";
|
||||
|
||||
/// Resolves the configured worktree directory to an absolute path.
|
||||
///
|
||||
/// `worktree_directory_setting` is the raw string from the user setting
|
||||
/// (e.g. `"../worktrees"`, `".git/zed-worktrees"`, `"my-worktrees/"`).
|
||||
/// Trailing slashes are stripped. The path is resolved relative to
|
||||
/// `working_directory` (the repository's working directory root).
|
||||
///
|
||||
/// When the resolved directory falls outside the working directory
|
||||
/// (e.g. `"../worktrees"`), the repository's directory name is
|
||||
/// automatically appended so that sibling repos don't collide.
|
||||
/// For example, with working directory `~/code/zed` and setting
|
||||
/// `"../worktrees"`, this returns `~/code/worktrees/zed`.
|
||||
///
|
||||
/// When the resolved directory is inside the working directory
|
||||
/// (e.g. `".git/zed-worktrees"`), no extra component is added
|
||||
/// because the path is already project-scoped.
|
||||
pub fn resolve_worktree_directory(
|
||||
working_directory: &Path,
|
||||
worktree_directory_setting: &str,
|
||||
) -> PathBuf {
|
||||
let trimmed = worktree_directory_setting.trim_end_matches(['/', '\\']);
|
||||
let joined = working_directory.join(trimmed);
|
||||
let resolved = normalize_path(&joined);
|
||||
|
||||
if resolved.starts_with(working_directory) {
|
||||
resolved
|
||||
} else if let Some(repo_dir_name) = working_directory.file_name() {
|
||||
resolved.join(repo_dir_name)
|
||||
} else {
|
||||
resolved
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates that the resolved worktree directory is acceptable:
|
||||
/// - The setting must not be an absolute path.
|
||||
/// - The resolved path must be either a subdirectory of the working
|
||||
/// directory or a subdirectory of its parent (i.e., a sibling).
|
||||
///
|
||||
/// Returns `Ok(resolved_path)` or an error with a user-facing message.
|
||||
pub fn validate_worktree_directory(
|
||||
working_directory: &Path,
|
||||
worktree_directory_setting: &str,
|
||||
) -> Result<PathBuf> {
|
||||
// Check the original setting before trimming, since a path like "///"
|
||||
// is absolute but becomes "" after stripping trailing separators.
|
||||
// Also check for leading `/` or `\` explicitly, because on Windows
|
||||
// `Path::is_absolute()` requires a drive letter — so `/tmp/worktrees`
|
||||
// would slip through even though it's clearly not a relative path.
|
||||
if Path::new(worktree_directory_setting).is_absolute()
|
||||
|| worktree_directory_setting.starts_with('/')
|
||||
|| worktree_directory_setting.starts_with('\\')
|
||||
{
|
||||
anyhow::bail!(
|
||||
"git.worktree_directory must be a relative path, got: {worktree_directory_setting:?}"
|
||||
);
|
||||
}
|
||||
|
||||
if worktree_directory_setting.is_empty() {
|
||||
anyhow::bail!("git.worktree_directory must not be empty");
|
||||
}
|
||||
|
||||
let trimmed = worktree_directory_setting.trim_end_matches(['/', '\\']);
|
||||
if trimmed == ".." {
|
||||
anyhow::bail!("git.worktree_directory must not be \"..\" (use \"../some-name\" instead)");
|
||||
}
|
||||
|
||||
let resolved = resolve_worktree_directory(working_directory, worktree_directory_setting);
|
||||
|
||||
let parent = working_directory.parent().unwrap_or(working_directory);
|
||||
|
||||
if !resolved.starts_with(parent) {
|
||||
anyhow::bail!(
|
||||
"git.worktree_directory resolved to {resolved:?}, which is outside \
|
||||
the project root and its parent directory. It must resolve to a \
|
||||
subdirectory of {working_directory:?} or a sibling of it."
|
||||
);
|
||||
}
|
||||
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
/// Returns the full absolute path for a specific branch's worktree
|
||||
/// given the resolved worktree directory.
|
||||
pub fn worktree_path_for_branch(
|
||||
working_directory: &Path,
|
||||
worktree_directory_setting: &str,
|
||||
branch: &str,
|
||||
) -> PathBuf {
|
||||
resolve_worktree_directory(working_directory, worktree_directory_setting).join(branch)
|
||||
}
|
||||
|
||||
/// Commit data needed for the git graph visualization.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GraphCommitData {
|
||||
@@ -1597,8 +1692,10 @@ impl GitRepository for RealGitRepository {
|
||||
} else {
|
||||
args.push(OsString::from("HEAD"));
|
||||
}
|
||||
|
||||
self.executor
|
||||
.spawn(async move {
|
||||
std::fs::create_dir_all(final_path.parent().unwrap_or(&final_path))?;
|
||||
let output = new_command(&git_binary_path)
|
||||
.current_dir(working_directory?)
|
||||
.args(args)
|
||||
@@ -3721,73 +3818,86 @@ mod tests {
|
||||
assert_eq!(result[0].ref_name.as_ref(), "refs/heads/main");
|
||||
}
|
||||
|
||||
const TEST_WORKTREE_DIRECTORIES: &[&str] =
|
||||
&["../worktrees", ".git/zed-worktrees", "my-worktrees/"];
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_create_and_list_worktrees(cx: &mut TestAppContext) {
|
||||
disable_git_global_config();
|
||||
cx.executor().allow_parking();
|
||||
|
||||
let repo_dir = tempfile::tempdir().unwrap();
|
||||
git2::Repository::init(repo_dir.path()).unwrap();
|
||||
for worktree_dir_setting in TEST_WORKTREE_DIRECTORIES {
|
||||
let repo_dir = tempfile::tempdir().unwrap();
|
||||
git2::Repository::init(repo_dir.path()).unwrap();
|
||||
|
||||
let repo = RealGitRepository::new(
|
||||
&repo_dir.path().join(".git"),
|
||||
None,
|
||||
Some("git".into()),
|
||||
cx.executor(),
|
||||
)
|
||||
.unwrap();
|
||||
let repo = RealGitRepository::new(
|
||||
&repo_dir.path().join(".git"),
|
||||
None,
|
||||
Some("git".into()),
|
||||
cx.executor(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Create an initial commit (required for worktrees)
|
||||
smol::fs::write(repo_dir.path().join("file.txt"), "content")
|
||||
// Create an initial commit (required for worktrees)
|
||||
smol::fs::write(repo_dir.path().join("file.txt"), "content")
|
||||
.await
|
||||
.unwrap();
|
||||
repo.stage_paths(vec![repo_path("file.txt")], Arc::new(HashMap::default()))
|
||||
.await
|
||||
.unwrap();
|
||||
repo.commit(
|
||||
"Initial commit".into(),
|
||||
None,
|
||||
CommitOptions::default(),
|
||||
AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
|
||||
Arc::new(checkpoint_author_envs()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
repo.stage_paths(vec![repo_path("file.txt")], Arc::new(HashMap::default()))
|
||||
|
||||
// List worktrees — should have just the main one
|
||||
let worktrees = repo.worktrees().await.unwrap();
|
||||
assert_eq!(worktrees.len(), 1);
|
||||
assert_eq!(
|
||||
worktrees[0].path.canonicalize().unwrap(),
|
||||
repo_dir.path().canonicalize().unwrap()
|
||||
);
|
||||
|
||||
// Create a new worktree
|
||||
repo.create_worktree(
|
||||
"test-branch".to_string(),
|
||||
resolve_worktree_directory(repo_dir.path(), worktree_dir_setting),
|
||||
Some("HEAD".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
repo.commit(
|
||||
"Initial commit".into(),
|
||||
None,
|
||||
CommitOptions::default(),
|
||||
AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
|
||||
Arc::new(checkpoint_author_envs()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// List worktrees — should have just the main one
|
||||
let worktrees = repo.worktrees().await.unwrap();
|
||||
assert_eq!(worktrees.len(), 1);
|
||||
assert_eq!(
|
||||
worktrees[0].path.canonicalize().unwrap(),
|
||||
repo_dir.path().canonicalize().unwrap()
|
||||
);
|
||||
// List worktrees — should have two
|
||||
let worktrees = repo.worktrees().await.unwrap();
|
||||
assert_eq!(worktrees.len(), 2);
|
||||
|
||||
// Create a new worktree
|
||||
let worktree_dir = tempfile::tempdir().unwrap();
|
||||
repo.create_worktree(
|
||||
"test-branch".to_string(),
|
||||
worktree_dir.path().to_path_buf(),
|
||||
Some("HEAD".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let expected_path =
|
||||
worktree_path_for_branch(repo_dir.path(), worktree_dir_setting, "test-branch");
|
||||
let new_worktree = worktrees
|
||||
.iter()
|
||||
.find(|w| w.branch() == "test-branch")
|
||||
.expect("should find worktree with test-branch");
|
||||
assert_eq!(
|
||||
new_worktree.path.canonicalize().unwrap(),
|
||||
expected_path.canonicalize().unwrap(),
|
||||
"failed for worktree_directory setting: {worktree_dir_setting:?}"
|
||||
);
|
||||
|
||||
// List worktrees — should have two
|
||||
let worktrees = repo.worktrees().await.unwrap();
|
||||
assert_eq!(worktrees.len(), 2);
|
||||
// Clean up so the next iteration starts fresh
|
||||
repo.remove_worktree(expected_path, true).await.unwrap();
|
||||
|
||||
let new_worktree = worktrees
|
||||
.iter()
|
||||
.find(|w| w.branch() == "test-branch")
|
||||
.expect("should find worktree with test-branch");
|
||||
assert_eq!(
|
||||
new_worktree.path.canonicalize().unwrap(),
|
||||
worktree_dir
|
||||
.path()
|
||||
.join("test-branch")
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
);
|
||||
// Clean up the worktree base directory if it was created outside repo_dir
|
||||
// (e.g. for the "../worktrees" setting, it won't be inside the TempDir)
|
||||
let resolved_dir = resolve_worktree_directory(repo_dir.path(), worktree_dir_setting);
|
||||
if !resolved_dir.starts_with(repo_dir.path()) {
|
||||
let _ = std::fs::remove_dir_all(&resolved_dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
@@ -3795,62 +3905,71 @@ mod tests {
|
||||
disable_git_global_config();
|
||||
cx.executor().allow_parking();
|
||||
|
||||
let repo_dir = tempfile::tempdir().unwrap();
|
||||
git2::Repository::init(repo_dir.path()).unwrap();
|
||||
for worktree_dir_setting in TEST_WORKTREE_DIRECTORIES {
|
||||
let repo_dir = tempfile::tempdir().unwrap();
|
||||
git2::Repository::init(repo_dir.path()).unwrap();
|
||||
|
||||
let repo = RealGitRepository::new(
|
||||
&repo_dir.path().join(".git"),
|
||||
None,
|
||||
Some("git".into()),
|
||||
cx.executor(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Create an initial commit
|
||||
smol::fs::write(repo_dir.path().join("file.txt"), "content")
|
||||
.await
|
||||
let repo = RealGitRepository::new(
|
||||
&repo_dir.path().join(".git"),
|
||||
None,
|
||||
Some("git".into()),
|
||||
cx.executor(),
|
||||
)
|
||||
.unwrap();
|
||||
repo.stage_paths(vec![repo_path("file.txt")], Arc::new(HashMap::default()))
|
||||
.await
|
||||
.unwrap();
|
||||
repo.commit(
|
||||
"Initial commit".into(),
|
||||
None,
|
||||
CommitOptions::default(),
|
||||
AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
|
||||
Arc::new(checkpoint_author_envs()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Create a worktree
|
||||
let worktree_dir = tempfile::tempdir().unwrap();
|
||||
repo.create_worktree(
|
||||
"to-remove".to_string(),
|
||||
worktree_dir.path().to_path_buf(),
|
||||
Some("HEAD".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let worktree_path = worktree_dir.path().join("to-remove");
|
||||
assert!(worktree_path.exists());
|
||||
|
||||
// Remove the worktree
|
||||
repo.remove_worktree(worktree_path.clone(), false)
|
||||
// Create an initial commit
|
||||
smol::fs::write(repo_dir.path().join("file.txt"), "content")
|
||||
.await
|
||||
.unwrap();
|
||||
repo.stage_paths(vec![repo_path("file.txt")], Arc::new(HashMap::default()))
|
||||
.await
|
||||
.unwrap();
|
||||
repo.commit(
|
||||
"Initial commit".into(),
|
||||
None,
|
||||
CommitOptions::default(),
|
||||
AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
|
||||
Arc::new(checkpoint_author_envs()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Verify it's gone from the list
|
||||
let worktrees = repo.worktrees().await.unwrap();
|
||||
assert_eq!(worktrees.len(), 1);
|
||||
assert!(
|
||||
worktrees.iter().all(|w| w.branch() != "to-remove"),
|
||||
"removed worktree should not appear in list"
|
||||
);
|
||||
// Create a worktree
|
||||
repo.create_worktree(
|
||||
"to-remove".to_string(),
|
||||
resolve_worktree_directory(repo_dir.path(), worktree_dir_setting),
|
||||
Some("HEAD".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Verify the directory is removed
|
||||
assert!(!worktree_path.exists());
|
||||
let worktree_path =
|
||||
worktree_path_for_branch(repo_dir.path(), worktree_dir_setting, "to-remove");
|
||||
assert!(worktree_path.exists());
|
||||
|
||||
// Remove the worktree
|
||||
repo.remove_worktree(worktree_path.clone(), false)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Verify it's gone from the list
|
||||
let worktrees = repo.worktrees().await.unwrap();
|
||||
assert_eq!(worktrees.len(), 1);
|
||||
assert!(
|
||||
worktrees.iter().all(|w| w.branch() != "to-remove"),
|
||||
"removed worktree should not appear in list"
|
||||
);
|
||||
|
||||
// Verify the directory is removed
|
||||
assert!(!worktree_path.exists());
|
||||
|
||||
// Clean up the worktree base directory if it was created outside repo_dir
|
||||
// (e.g. for the "../worktrees" setting, it won't be inside the TempDir)
|
||||
let resolved_dir = resolve_worktree_directory(repo_dir.path(), worktree_dir_setting);
|
||||
if !resolved_dir.starts_with(repo_dir.path()) {
|
||||
let _ = std::fs::remove_dir_all(&resolved_dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
@@ -3858,66 +3977,75 @@ mod tests {
|
||||
disable_git_global_config();
|
||||
cx.executor().allow_parking();
|
||||
|
||||
let repo_dir = tempfile::tempdir().unwrap();
|
||||
git2::Repository::init(repo_dir.path()).unwrap();
|
||||
for worktree_dir_setting in TEST_WORKTREE_DIRECTORIES {
|
||||
let repo_dir = tempfile::tempdir().unwrap();
|
||||
git2::Repository::init(repo_dir.path()).unwrap();
|
||||
|
||||
let repo = RealGitRepository::new(
|
||||
&repo_dir.path().join(".git"),
|
||||
None,
|
||||
Some("git".into()),
|
||||
cx.executor(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Create an initial commit
|
||||
smol::fs::write(repo_dir.path().join("file.txt"), "content")
|
||||
.await
|
||||
let repo = RealGitRepository::new(
|
||||
&repo_dir.path().join(".git"),
|
||||
None,
|
||||
Some("git".into()),
|
||||
cx.executor(),
|
||||
)
|
||||
.unwrap();
|
||||
repo.stage_paths(vec![repo_path("file.txt")], Arc::new(HashMap::default()))
|
||||
.await
|
||||
.unwrap();
|
||||
repo.commit(
|
||||
"Initial commit".into(),
|
||||
None,
|
||||
CommitOptions::default(),
|
||||
AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
|
||||
Arc::new(checkpoint_author_envs()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Create a worktree
|
||||
let worktree_dir = tempfile::tempdir().unwrap();
|
||||
repo.create_worktree(
|
||||
"dirty-wt".to_string(),
|
||||
worktree_dir.path().to_path_buf(),
|
||||
Some("HEAD".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let worktree_path = worktree_dir.path().join("dirty-wt");
|
||||
|
||||
// Add uncommitted changes in the worktree
|
||||
smol::fs::write(worktree_path.join("dirty-file.txt"), "uncommitted")
|
||||
// Create an initial commit
|
||||
smol::fs::write(repo_dir.path().join("file.txt"), "content")
|
||||
.await
|
||||
.unwrap();
|
||||
repo.stage_paths(vec![repo_path("file.txt")], Arc::new(HashMap::default()))
|
||||
.await
|
||||
.unwrap();
|
||||
repo.commit(
|
||||
"Initial commit".into(),
|
||||
None,
|
||||
CommitOptions::default(),
|
||||
AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
|
||||
Arc::new(checkpoint_author_envs()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Non-force removal should fail with dirty worktree
|
||||
let result = repo.remove_worktree(worktree_path.clone(), false).await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"non-force removal of dirty worktree should fail"
|
||||
);
|
||||
|
||||
// Force removal should succeed
|
||||
repo.remove_worktree(worktree_path.clone(), true)
|
||||
// Create a worktree
|
||||
repo.create_worktree(
|
||||
"dirty-wt".to_string(),
|
||||
resolve_worktree_directory(repo_dir.path(), worktree_dir_setting),
|
||||
Some("HEAD".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let worktrees = repo.worktrees().await.unwrap();
|
||||
assert_eq!(worktrees.len(), 1);
|
||||
assert!(!worktree_path.exists());
|
||||
let worktree_path =
|
||||
worktree_path_for_branch(repo_dir.path(), worktree_dir_setting, "dirty-wt");
|
||||
|
||||
// Add uncommitted changes in the worktree
|
||||
smol::fs::write(worktree_path.join("dirty-file.txt"), "uncommitted")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Non-force removal should fail with dirty worktree
|
||||
let result = repo.remove_worktree(worktree_path.clone(), false).await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"non-force removal of dirty worktree should fail"
|
||||
);
|
||||
|
||||
// Force removal should succeed
|
||||
repo.remove_worktree(worktree_path.clone(), true)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let worktrees = repo.worktrees().await.unwrap();
|
||||
assert_eq!(worktrees.len(), 1);
|
||||
assert!(!worktree_path.exists());
|
||||
|
||||
// Clean up the worktree base directory if it was created outside repo_dir
|
||||
// (e.g. for the "../worktrees" setting, it won't be inside the TempDir)
|
||||
let resolved_dir = resolve_worktree_directory(repo_dir.path(), worktree_dir_setting);
|
||||
if !resolved_dir.starts_with(repo_dir.path()) {
|
||||
let _ = std::fs::remove_dir_all(&resolved_dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
@@ -3925,67 +4053,206 @@ mod tests {
|
||||
disable_git_global_config();
|
||||
cx.executor().allow_parking();
|
||||
|
||||
let repo_dir = tempfile::tempdir().unwrap();
|
||||
git2::Repository::init(repo_dir.path()).unwrap();
|
||||
for worktree_dir_setting in TEST_WORKTREE_DIRECTORIES {
|
||||
let repo_dir = tempfile::tempdir().unwrap();
|
||||
git2::Repository::init(repo_dir.path()).unwrap();
|
||||
|
||||
let repo = RealGitRepository::new(
|
||||
&repo_dir.path().join(".git"),
|
||||
None,
|
||||
Some("git".into()),
|
||||
cx.executor(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Create an initial commit
|
||||
smol::fs::write(repo_dir.path().join("file.txt"), "content")
|
||||
.await
|
||||
let repo = RealGitRepository::new(
|
||||
&repo_dir.path().join(".git"),
|
||||
None,
|
||||
Some("git".into()),
|
||||
cx.executor(),
|
||||
)
|
||||
.unwrap();
|
||||
repo.stage_paths(vec![repo_path("file.txt")], Arc::new(HashMap::default()))
|
||||
.await
|
||||
.unwrap();
|
||||
repo.commit(
|
||||
"Initial commit".into(),
|
||||
None,
|
||||
CommitOptions::default(),
|
||||
AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
|
||||
Arc::new(checkpoint_author_envs()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Create a worktree
|
||||
let worktree_dir = tempfile::tempdir().unwrap();
|
||||
repo.create_worktree(
|
||||
"old-name".to_string(),
|
||||
worktree_dir.path().to_path_buf(),
|
||||
Some("HEAD".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let old_path = worktree_dir.path().join("old-name");
|
||||
assert!(old_path.exists());
|
||||
|
||||
// Move the worktree to a new path
|
||||
let new_path = worktree_dir.path().join("new-name");
|
||||
repo.rename_worktree(old_path.clone(), new_path.clone())
|
||||
// Create an initial commit
|
||||
smol::fs::write(repo_dir.path().join("file.txt"), "content")
|
||||
.await
|
||||
.unwrap();
|
||||
repo.stage_paths(vec![repo_path("file.txt")], Arc::new(HashMap::default()))
|
||||
.await
|
||||
.unwrap();
|
||||
repo.commit(
|
||||
"Initial commit".into(),
|
||||
None,
|
||||
CommitOptions::default(),
|
||||
AskPassDelegate::new(&mut cx.to_async(), |_, _, _| {}),
|
||||
Arc::new(checkpoint_author_envs()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Verify the old path is gone and new path exists
|
||||
assert!(!old_path.exists());
|
||||
assert!(new_path.exists());
|
||||
// Create a worktree
|
||||
repo.create_worktree(
|
||||
"old-name".to_string(),
|
||||
resolve_worktree_directory(repo_dir.path(), worktree_dir_setting),
|
||||
Some("HEAD".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Verify it shows up in worktree list at the new path
|
||||
let worktrees = repo.worktrees().await.unwrap();
|
||||
assert_eq!(worktrees.len(), 2);
|
||||
let moved_worktree = worktrees
|
||||
.iter()
|
||||
.find(|w| w.branch() == "old-name")
|
||||
.expect("should find worktree by branch name");
|
||||
let old_path =
|
||||
worktree_path_for_branch(repo_dir.path(), worktree_dir_setting, "old-name");
|
||||
assert!(old_path.exists());
|
||||
|
||||
// Move the worktree to a new path
|
||||
let new_path =
|
||||
resolve_worktree_directory(repo_dir.path(), worktree_dir_setting).join("new-name");
|
||||
repo.rename_worktree(old_path.clone(), new_path.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Verify the old path is gone and new path exists
|
||||
assert!(!old_path.exists());
|
||||
assert!(new_path.exists());
|
||||
|
||||
// Verify it shows up in worktree list at the new path
|
||||
let worktrees = repo.worktrees().await.unwrap();
|
||||
assert_eq!(worktrees.len(), 2);
|
||||
let moved_worktree = worktrees
|
||||
.iter()
|
||||
.find(|w| w.branch() == "old-name")
|
||||
.expect("should find worktree by branch name");
|
||||
assert_eq!(
|
||||
moved_worktree.path.canonicalize().unwrap(),
|
||||
new_path.canonicalize().unwrap()
|
||||
);
|
||||
|
||||
// Clean up so the next iteration starts fresh
|
||||
repo.remove_worktree(new_path, true).await.unwrap();
|
||||
|
||||
// Clean up the worktree base directory if it was created outside repo_dir
|
||||
// (e.g. for the "../worktrees" setting, it won't be inside the TempDir)
|
||||
let resolved_dir = resolve_worktree_directory(repo_dir.path(), worktree_dir_setting);
|
||||
if !resolved_dir.starts_with(repo_dir.path()) {
|
||||
let _ = std::fs::remove_dir_all(&resolved_dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_worktree_directory() {
|
||||
let work_dir = Path::new("/code/my-project");
|
||||
|
||||
// Sibling directory — outside project, so repo dir name is appended
|
||||
assert_eq!(
|
||||
moved_worktree.path.canonicalize().unwrap(),
|
||||
new_path.canonicalize().unwrap()
|
||||
resolve_worktree_directory(work_dir, "../worktrees"),
|
||||
PathBuf::from("/code/worktrees/my-project")
|
||||
);
|
||||
|
||||
// Git subdir — inside project, no repo name appended
|
||||
assert_eq!(
|
||||
resolve_worktree_directory(work_dir, ".git/zed-worktrees"),
|
||||
PathBuf::from("/code/my-project/.git/zed-worktrees")
|
||||
);
|
||||
|
||||
// Simple subdir — inside project, no repo name appended
|
||||
assert_eq!(
|
||||
resolve_worktree_directory(work_dir, "my-worktrees"),
|
||||
PathBuf::from("/code/my-project/my-worktrees")
|
||||
);
|
||||
|
||||
// Trailing slash is stripped
|
||||
assert_eq!(
|
||||
resolve_worktree_directory(work_dir, "../worktrees/"),
|
||||
PathBuf::from("/code/worktrees/my-project")
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_worktree_directory(work_dir, "my-worktrees/"),
|
||||
PathBuf::from("/code/my-project/my-worktrees")
|
||||
);
|
||||
|
||||
// Multiple trailing slashes
|
||||
assert_eq!(
|
||||
resolve_worktree_directory(work_dir, "foo///"),
|
||||
PathBuf::from("/code/my-project/foo")
|
||||
);
|
||||
|
||||
// Trailing backslashes (Windows-style)
|
||||
assert_eq!(
|
||||
resolve_worktree_directory(work_dir, "my-worktrees\\"),
|
||||
PathBuf::from("/code/my-project/my-worktrees")
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_worktree_directory(work_dir, "foo\\/\\"),
|
||||
PathBuf::from("/code/my-project/foo")
|
||||
);
|
||||
|
||||
// Empty string resolves to the working directory itself (inside)
|
||||
assert_eq!(
|
||||
resolve_worktree_directory(work_dir, ""),
|
||||
PathBuf::from("/code/my-project")
|
||||
);
|
||||
|
||||
// Just ".." — outside project, repo dir name appended
|
||||
assert_eq!(
|
||||
resolve_worktree_directory(work_dir, ".."),
|
||||
PathBuf::from("/code/my-project")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_worktree_directory() {
|
||||
let work_dir = Path::new("/code/my-project");
|
||||
|
||||
// Valid: sibling
|
||||
assert!(validate_worktree_directory(work_dir, "../worktrees").is_ok());
|
||||
|
||||
// Valid: subdirectory
|
||||
assert!(validate_worktree_directory(work_dir, ".git/zed-worktrees").is_ok());
|
||||
assert!(validate_worktree_directory(work_dir, "my-worktrees").is_ok());
|
||||
|
||||
// Invalid: just ".." would resolve back to the working directory itself
|
||||
let err = validate_worktree_directory(work_dir, "..").unwrap_err();
|
||||
assert!(err.to_string().contains("must not be \"..\""));
|
||||
|
||||
// Invalid: ".." with trailing separators
|
||||
let err = validate_worktree_directory(work_dir, "..\\").unwrap_err();
|
||||
assert!(err.to_string().contains("must not be \"..\""));
|
||||
let err = validate_worktree_directory(work_dir, "../").unwrap_err();
|
||||
assert!(err.to_string().contains("must not be \"..\""));
|
||||
|
||||
// Invalid: empty string would resolve to the working directory itself
|
||||
let err = validate_worktree_directory(work_dir, "").unwrap_err();
|
||||
assert!(err.to_string().contains("must not be empty"));
|
||||
|
||||
// Invalid: absolute path
|
||||
let err = validate_worktree_directory(work_dir, "/tmp/worktrees").unwrap_err();
|
||||
assert!(err.to_string().contains("relative path"));
|
||||
|
||||
// Invalid: "/" is absolute on Unix
|
||||
let err = validate_worktree_directory(work_dir, "/").unwrap_err();
|
||||
assert!(err.to_string().contains("relative path"));
|
||||
|
||||
// Invalid: "///" is absolute
|
||||
let err = validate_worktree_directory(work_dir, "///").unwrap_err();
|
||||
assert!(err.to_string().contains("relative path"));
|
||||
|
||||
// Invalid: escapes too far up
|
||||
let err = validate_worktree_directory(work_dir, "../../other-project/wt").unwrap_err();
|
||||
assert!(err.to_string().contains("outside"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_worktree_path_for_branch() {
|
||||
let work_dir = Path::new("/code/my-project");
|
||||
|
||||
// Outside project — repo dir name is part of the resolved directory
|
||||
assert_eq!(
|
||||
worktree_path_for_branch(work_dir, "../worktrees", "feature/foo"),
|
||||
PathBuf::from("/code/worktrees/my-project/feature/foo")
|
||||
);
|
||||
|
||||
// Inside project — no repo dir name inserted
|
||||
assert_eq!(
|
||||
worktree_path_for_branch(work_dir, ".git/zed-worktrees", "my-branch"),
|
||||
PathBuf::from("/code/my-project/.git/zed-worktrees/my-branch")
|
||||
);
|
||||
|
||||
// Trailing slash on setting (inside project)
|
||||
assert_eq!(
|
||||
worktree_path_for_branch(work_dir, "my-worktrees/", "branch"),
|
||||
PathBuf::from("/code/my-project/my-worktrees/branch")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,21 +2,21 @@ use anyhow::Context as _;
|
||||
use collections::HashSet;
|
||||
use fuzzy::StringMatchCandidate;
|
||||
|
||||
use git::repository::Worktree as GitWorktree;
|
||||
use git::repository::{Worktree as GitWorktree, validate_worktree_directory};
|
||||
use gpui::{
|
||||
Action, App, AsyncWindowContext, Context, DismissEvent, Entity, EventEmitter, FocusHandle,
|
||||
Focusable, InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement,
|
||||
PathPromptOptions, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window,
|
||||
actions, rems,
|
||||
Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, actions, rems,
|
||||
};
|
||||
use picker::{Picker, PickerDelegate, PickerEditorPosition};
|
||||
use project::project_settings::ProjectSettings;
|
||||
use project::{
|
||||
DirectoryLister,
|
||||
git_store::Repository,
|
||||
trusted_worktrees::{PathTrust, TrustedWorktrees},
|
||||
};
|
||||
use remote::{RemoteConnectionOptions, remote_client::ConnectionIdentifier};
|
||||
use remote_connection::{RemoteConnectionModal, connect};
|
||||
use settings::Settings;
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
use ui::{HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, prelude::*};
|
||||
use util::ResultExt;
|
||||
@@ -267,40 +267,22 @@ impl WorktreeListDelegate {
|
||||
return;
|
||||
};
|
||||
|
||||
let worktree_path = self
|
||||
.workspace
|
||||
.clone()
|
||||
.update(cx, |this, cx| {
|
||||
this.prompt_for_open_path(
|
||||
PathPromptOptions {
|
||||
files: false,
|
||||
directories: true,
|
||||
multiple: false,
|
||||
prompt: Some("Select directory for new worktree".into()),
|
||||
},
|
||||
DirectoryLister::Project(this.project().clone()),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.log_err();
|
||||
let Some(worktree_path) = worktree_path else {
|
||||
return;
|
||||
};
|
||||
|
||||
let branch = worktree_branch.to_string();
|
||||
let workspace = self.workspace.clone();
|
||||
cx.spawn_in(window, async move |_, cx| {
|
||||
let Some(paths) = worktree_path.await? else {
|
||||
return anyhow::Ok(());
|
||||
};
|
||||
let path = paths.get(0).cloned().context("No path selected")?;
|
||||
|
||||
repo.update(cx, |repo, _| {
|
||||
repo.create_worktree(branch.clone(), path.clone(), commit)
|
||||
})
|
||||
.await??;
|
||||
let new_worktree_path = path.join(branch);
|
||||
let (receiver, new_worktree_path) = repo.update(cx, |repo, cx| {
|
||||
let worktree_directory_setting = ProjectSettings::get_global(cx)
|
||||
.git
|
||||
.worktree_directory
|
||||
.clone();
|
||||
let work_dir = repo.work_directory_abs_path.clone();
|
||||
let directory =
|
||||
validate_worktree_directory(&work_dir, &worktree_directory_setting)?;
|
||||
let new_worktree_path = directory.join(&branch);
|
||||
let receiver = repo.create_worktree(branch.clone(), directory, commit);
|
||||
anyhow::Ok((receiver, new_worktree_path))
|
||||
})?;
|
||||
receiver.await??;
|
||||
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) {
|
||||
@@ -364,7 +346,12 @@ impl WorktreeListDelegate {
|
||||
anyhow::Ok(())
|
||||
})
|
||||
.detach_and_prompt_err("Failed to create worktree", window, cx, |e, _, _| {
|
||||
Some(e.to_string())
|
||||
let msg = e.to_string();
|
||||
if msg.contains("git.worktree_directory") {
|
||||
Some(format!("Invalid git.worktree_directory setting: {}", e))
|
||||
} else {
|
||||
Some(msg)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ impl Settings for OutlinePanelSettings {
|
||||
git_status: panel.git_status.unwrap()
|
||||
&& content
|
||||
.git
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.enabled
|
||||
.unwrap()
|
||||
|
||||
@@ -5541,7 +5541,7 @@ impl Repository {
|
||||
pub fn create_worktree(
|
||||
&mut self,
|
||||
name: String,
|
||||
path: PathBuf,
|
||||
directory: PathBuf,
|
||||
commit: Option<String>,
|
||||
) -> oneshot::Receiver<Result<()>> {
|
||||
let id = self.id;
|
||||
@@ -5550,7 +5550,7 @@ impl Repository {
|
||||
move |repo, _cx| async move {
|
||||
match repo {
|
||||
RepositoryState::Local(LocalRepositoryState { backend, .. }) => {
|
||||
backend.create_worktree(name, path, commit).await
|
||||
backend.create_worktree(name, directory, commit).await
|
||||
}
|
||||
RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => {
|
||||
client
|
||||
@@ -5558,7 +5558,7 @@ impl Repository {
|
||||
project_id: project_id.0,
|
||||
repository_id: id.to_proto(),
|
||||
name,
|
||||
directory: path.to_string_lossy().to_string(),
|
||||
directory: directory.to_string_lossy().to_string(),
|
||||
commit,
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -4,6 +4,7 @@ use context_server::ContextServerCommand;
|
||||
use dap::adapters::DebugAdapterName;
|
||||
use fs::Fs;
|
||||
use futures::StreamExt as _;
|
||||
use git::repository::DEFAULT_WORKTREE_DIRECTORY;
|
||||
use gpui::{AsyncApp, BorrowAppContext, Context, Entity, EventEmitter, Subscription, Task};
|
||||
use lsp::{DEFAULT_LSP_REQUEST_TIMEOUT_SECS, LanguageServerName};
|
||||
use paths::{
|
||||
@@ -421,7 +422,7 @@ impl GoToDiagnosticSeverityFilter {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GitSettings {
|
||||
/// Whether or not git integration is enabled.
|
||||
///
|
||||
@@ -454,6 +455,13 @@ pub struct GitSettings {
|
||||
///
|
||||
/// Default: file_name_first
|
||||
pub path_style: GitPathStyle,
|
||||
/// Directory where git worktrees are created, relative to the repository
|
||||
/// working directory. When the resolved directory is outside the project
|
||||
/// root, the project's directory name is automatically appended so that
|
||||
/// sibling repos don't collide.
|
||||
///
|
||||
/// Default: ../worktrees
|
||||
pub worktree_directory: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -643,6 +651,10 @@ impl Settings for ProjectSettings {
|
||||
},
|
||||
hunk_style: git.hunk_style.unwrap(),
|
||||
path_style: git.path_style.unwrap().into(),
|
||||
worktree_directory: git
|
||||
.worktree_directory
|
||||
.clone()
|
||||
.unwrap_or_else(|| DEFAULT_WORKTREE_DIRECTORY.to_string()),
|
||||
};
|
||||
Self {
|
||||
context_servers: project
|
||||
|
||||
@@ -96,6 +96,7 @@ impl Settings for ProjectPanelSettings {
|
||||
git_status: project_panel.git_status.unwrap()
|
||||
&& content
|
||||
.git
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.enabled
|
||||
.unwrap()
|
||||
|
||||
@@ -439,7 +439,7 @@ impl std::fmt::Debug for ContextServerCommand {
|
||||
}
|
||||
|
||||
#[with_fallible_options]
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Default, Serialize, Deserialize, JsonSchema, MergeFrom)]
|
||||
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize, JsonSchema, MergeFrom)]
|
||||
pub struct GitSettings {
|
||||
/// Whether or not to enable git integration.
|
||||
///
|
||||
@@ -473,6 +473,27 @@ pub struct GitSettings {
|
||||
///
|
||||
/// Default: file_name_first
|
||||
pub path_style: Option<GitPathStyle>,
|
||||
/// Directory where git worktrees are created, relative to the repository
|
||||
/// working directory.
|
||||
///
|
||||
/// When the resolved directory is outside the project root, the
|
||||
/// project's directory name is automatically appended so that
|
||||
/// sibling repos don't collide. For example, with the default
|
||||
/// `"../worktrees"` and a project at `~/code/zed`, worktrees are
|
||||
/// created under `~/code/worktrees/zed/`.
|
||||
///
|
||||
/// When the resolved directory is inside the project root, no
|
||||
/// extra component is added (it's already project-scoped).
|
||||
///
|
||||
/// Examples:
|
||||
/// - `"../worktrees"` — `~/code/worktrees/<project>/` (default)
|
||||
/// - `".git/zed-worktrees"` — `<project>/.git/zed-worktrees/`
|
||||
/// - `"my-worktrees"` — `<project>/my-worktrees/`
|
||||
///
|
||||
/// Trailing slashes are ignored.
|
||||
///
|
||||
/// Default: ../worktrees
|
||||
pub worktree_directory: Option<String>,
|
||||
}
|
||||
|
||||
#[with_fallible_options]
|
||||
|
||||
+31
-1
@@ -22,7 +22,7 @@ use futures::Future;
|
||||
use itertools::Either;
|
||||
use paths::PathExt;
|
||||
use regex::Regex;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{LazyLock, OnceLock};
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
@@ -1055,6 +1055,36 @@ pub fn some_or_debug_panic<T>(option: Option<T>) -> Option<T> {
|
||||
option
|
||||
}
|
||||
|
||||
/// Normalizes a path by resolving `.` and `..` components without
|
||||
/// requiring the path to exist on disk (unlike `canonicalize`).
|
||||
pub fn normalize_path(path: &Path) -> PathBuf {
|
||||
use std::path::Component;
|
||||
let mut components = path.components().peekable();
|
||||
let mut ret = if let Some(c @ Component::Prefix(..)) = components.peek().cloned() {
|
||||
components.next();
|
||||
PathBuf::from(c.as_os_str())
|
||||
} else {
|
||||
PathBuf::new()
|
||||
};
|
||||
|
||||
for component in components {
|
||||
match component {
|
||||
Component::Prefix(..) => unreachable!(),
|
||||
Component::RootDir => {
|
||||
ret.push(component.as_os_str());
|
||||
}
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir => {
|
||||
ret.pop();
|
||||
}
|
||||
Component::Normal(c) => {
|
||||
ret.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
ret
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -79,6 +79,7 @@ impl Settings for ItemSettings {
|
||||
git_status: tabs.git_status.unwrap()
|
||||
&& content
|
||||
.git
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.enabled
|
||||
.unwrap()
|
||||
|
||||
Reference in new issue
Block a user