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:
Richard Feldman authored and GitHub committed 2026-02-18 22:20:31 +00:00
1 parent fc80cd4c25
commit e8a72de57f
16 files changed
+860 -391

No files matched your search

+19
View File
@@ -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
View File
@@ -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"
);
}
}
+9 -4
View File
@@ -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 {
+1
View File
@@ -162,6 +162,7 @@ mod tests {
profile: None,
imported: false,
subagent_context: None,
git_worktree_info: None,
}
}
+7 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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")
);
}
+23 -36
View File
@@ -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()
+3 -3
View File
@@ -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?;
+13 -1
View File
@@ -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()
+22 -1
View File
@@ -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
View File
@@ -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::*;
+1
View File
@@ -79,6 +79,7 @@ impl Settings for ItemSettings {
git_status: tabs.git_status.unwrap()
&& content
.git
.as_ref()
.unwrap()
.enabled
.unwrap()