git: Add create git worktree hook to task system (#51337)

### Summary

This PR starts work on adding basic hook support in the `TaskStore`. To
enable users to setup tasks that are ran when the agent panel creates a
new git worktree to start a thread in. It also adds a new task variable
called `ZED_MAIN_GIT_WORKTREE` that's the absolute path to the main repo
that the newly created linked worktree is based off of.

### Follow Ups 

- Get this hook working with the git worktree picker as well
- Make a more general approach to the hook system in `TaskStore`
- Add `ZED_PORT_{1..10}` task variables
- Migrate the task context creation code from `task_ui` to the basic
context provider

Before you mark this PR as ready for review, make sure that you have:
- [ ] Added a solid test coverage and/or screenshots from doing manual
testing
- [x] Done a self-review taking into account security and performance
aspects
- [x] Aligned any UI changes with the [UI
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)

Release Notes:

- N/A *or* Added/Fixed/Improved ...

---------

Co-authored-by: Remco Smits <djsmits12@gmail.com>
Co-authored-by: Richard Feldman <oss@rtfeldman.com>
This commit is contained in:
authored and GitHub committed 2026-04-01 16:14:48 -04:00
1 parent fb5dbc0940
commit 5d9469280c
13 files changed
+259 -63

No files matched your search

+3 -3
View File
@@ -4,13 +4,13 @@
"command": "./script/clippy",
"args": [],
"allow_concurrent_runs": true,
"use_new_terminal": false
"use_new_terminal": false,
},
{
"label": "cargo run --profile release-fast",
"command": "cargo",
"args": ["run", "--profile", "release-fast"],
"allow_concurrent_runs": true,
"use_new_terminal": false
}
"use_new_terminal": false,
},
]
+5 -1
View File
@@ -2588,7 +2588,7 @@ impl AgentPanel {
anyhow::Ok(())
});
self._worktree_creation_task = Some(cx.foreground_executor().spawn(async move {
self._worktree_creation_task = Some(cx.background_spawn(async move {
task.await.log_err();
}));
}
@@ -2745,6 +2745,10 @@ impl AgentPanel {
new_window_handle.update(cx, |multi_workspace, window, cx| {
multi_workspace.activate(new_workspace.clone(), window, cx);
new_workspace.update(cx, |workspace, cx| {
workspace.run_create_worktree_tasks(window, cx);
})
})?;
this.update_in(cx, |this, window, cx| {
+20
View File
@@ -1799,6 +1799,26 @@ impl GitStore {
&self.repositories
}
/// Returns the original (main) repository working directory for the given worktree.
/// For normal checkouts this equals the worktree's own path; for linked
/// worktrees it points back to the original repo.
pub fn original_repo_path_for_worktree(
&self,
worktree_id: WorktreeId,
cx: &App,
) -> Option<Arc<Path>> {
self.active_repo_id
.iter()
.chain(self.worktree_ids.keys())
.find(|repo_id| {
self.worktree_ids
.get(repo_id)
.is_some_and(|ids| ids.contains(&worktree_id))
})
.and_then(|repo_id| self.repositories.get(repo_id))
.map(|repo| repo.read(cx).snapshot().original_repo_abs_path)
}
pub fn status_for_buffer_id(&self, buffer_id: BufferId, cx: &App) -> Option<FileStatus> {
let (repo, path) = self.repository_and_path_for_buffer_id(buffer_id, cx)?;
let status = repo.read(cx).snapshot.status_for_path(&path)?;
+48 -45
View File
@@ -1230,12 +1230,23 @@ impl Project {
)
});
let git_store = cx.new(|cx| {
GitStore::local(
&worktree_store,
buffer_store.clone(),
environment.clone(),
fs.clone(),
cx,
)
});
let task_store = cx.new(|cx| {
TaskStore::local(
buffer_store.downgrade(),
worktree_store.clone(),
toolchain_store.read(cx).as_language_toolchain_store(),
environment.clone(),
git_store.clone(),
cx,
)
});
@@ -1271,16 +1282,6 @@ impl Project {
)
});
let git_store = cx.new(|cx| {
GitStore::local(
&worktree_store,
buffer_store.clone(),
environment.clone(),
fs.clone(),
cx,
)
});
let agent_server_store = cx.new(|cx| {
AgentServerStore::local(
node.clone(),
@@ -1415,30 +1416,6 @@ impl Project {
)
});
let task_store = cx.new(|cx| {
TaskStore::remote(
buffer_store.downgrade(),
worktree_store.clone(),
toolchain_store.read(cx).as_language_toolchain_store(),
remote.read(cx).proto_client(),
REMOTE_SERVER_PROJECT_ID,
cx,
)
});
let settings_observer = cx.new(|cx| {
SettingsObserver::new_remote(
fs.clone(),
worktree_store.clone(),
task_store.clone(),
Some(remote_proto.clone()),
false,
cx,
)
});
cx.subscribe(&settings_observer, Self::on_settings_observer_event)
.detach();
let context_server_store = cx.new(|cx| {
ContextServerStore::remote(
rpc::proto::REMOTE_SERVER_PROJECT_ID,
@@ -1503,6 +1480,31 @@ impl Project {
)
});
let task_store = cx.new(|cx| {
TaskStore::remote(
buffer_store.downgrade(),
worktree_store.clone(),
toolchain_store.read(cx).as_language_toolchain_store(),
remote.read(cx).proto_client(),
REMOTE_SERVER_PROJECT_ID,
git_store.clone(),
cx,
)
});
let settings_observer = cx.new(|cx| {
SettingsObserver::new_remote(
fs.clone(),
worktree_store.clone(),
task_store.clone(),
Some(remote_proto.clone()),
false,
cx,
)
});
cx.subscribe(&settings_observer, Self::on_settings_observer_event)
.detach();
let agent_server_store = cx.new(|_| {
AgentServerStore::remote(
REMOTE_SERVER_PROJECT_ID,
@@ -1732,6 +1734,17 @@ impl Project {
)
});
let git_store = cx.new(|cx| {
GitStore::remote(
// In this remote case we pass None for the environment
&worktree_store,
buffer_store.clone(),
client.clone().into(),
remote_id,
cx,
)
});
let task_store = cx.new(|cx| {
if run_tasks {
TaskStore::remote(
@@ -1740,6 +1753,7 @@ impl Project {
Arc::new(EmptyToolchainStore),
client.clone().into(),
remote_id,
git_store.clone(),
cx,
)
} else {
@@ -1758,17 +1772,6 @@ impl Project {
)
});
let git_store = cx.new(|cx| {
GitStore::remote(
// In this remote case we pass None for the environment
&worktree_store,
buffer_store.clone(),
client.clone().into(),
remote_id,
cx,
)
});
let agent_server_store = cx.new(|_cx| AgentServerStore::collab());
let replica_id = ReplicaId::new(response.payload.replica_id as u16);
+34 -4
View File
@@ -21,14 +21,14 @@ use lsp::{LanguageServerId, LanguageServerName};
use paths::{debug_task_file_name, task_file_name};
use settings::{InvalidSettingsError, parse_json_with_comments};
use task::{
DebugScenario, ResolvedTask, SharedTaskContext, TaskContext, TaskId, TaskTemplate,
DebugScenario, ResolvedTask, SharedTaskContext, TaskContext, TaskHook, TaskId, TaskTemplate,
TaskTemplates, TaskVariables, VariableName,
};
use text::{BufferId, Point, ToPoint};
use util::{NumericPrefixWithSuffix, ResultExt as _, post_inc, rel_path::RelPath};
use worktree::WorktreeId;
use crate::{task_store::TaskSettingsLocation, worktree_store::WorktreeStore};
use crate::{git_store::GitStore, task_store::TaskSettingsLocation, worktree_store::WorktreeStore};
#[derive(Clone, Debug, Default)]
pub struct DebugScenarioContext {
@@ -644,6 +644,19 @@ impl Inventory {
self.last_scheduled_tasks.retain(|(_, task)| &task.id != id);
}
/// Returns all task templates (worktree and global) that have at least one
/// hook in the provided set.
pub fn templates_with_hooks(
&self,
hooks: &HashSet<TaskHook>,
worktree: WorktreeId,
) -> Vec<(TaskSourceKind, TaskTemplate)> {
self.worktree_templates_from_settings(worktree)
.chain(self.global_templates_from_settings())
.filter(|(_, template)| !template.hooks.is_disjoint(hooks))
.collect()
}
fn global_templates_from_settings(
&self,
) -> impl '_ + Iterator<Item = (TaskSourceKind, TaskTemplate)> {
@@ -918,11 +931,15 @@ fn task_variables_preference(task: &ResolvedTask) -> Reverse<usize> {
/// Applied as a base for every custom [`ContextProvider`] unless explicitly oped out.
pub struct BasicContextProvider {
worktree_store: Entity<WorktreeStore>,
git_store: Entity<GitStore>,
}
impl BasicContextProvider {
pub fn new(worktree_store: Entity<WorktreeStore>) -> Self {
Self { worktree_store }
pub fn new(worktree_store: Entity<WorktreeStore>, git_store: Entity<GitStore>) -> Self {
Self {
worktree_store,
git_store,
}
}
}
@@ -1002,6 +1019,19 @@ impl ContextProvider for BasicContextProvider {
}
}
if let Some(worktree_id) = location.buffer.read(cx).file().map(|f| f.worktree_id(cx)) {
if let Some(path) = self
.git_store
.read(cx)
.original_repo_path_for_worktree(worktree_id, cx)
{
task_variables.insert(
VariableName::MainGitWorktree,
path.to_string_lossy().into_owned(),
);
}
}
if let Some(current_file) = current_file {
let path = current_file.abs_path(cx);
if let Some(filename) = path.file_name().and_then(|f| f.to_str()) {
+12 -3
View File
@@ -19,7 +19,7 @@ use util::ResultExt;
use crate::{
BasicContextProvider, Inventory, ProjectEnvironment, buffer_store::BufferStore,
worktree_store::WorktreeStore,
git_store::GitStore, worktree_store::WorktreeStore,
};
// platform-dependent warning
@@ -33,6 +33,7 @@ pub struct StoreState {
task_inventory: Entity<Inventory>,
buffer_store: WeakEntity<BufferStore>,
worktree_store: Entity<WorktreeStore>,
git_store: Entity<GitStore>,
toolchain_store: Arc<dyn LanguageToolchainStore>,
}
@@ -163,6 +164,7 @@ impl TaskStore {
worktree_store: Entity<WorktreeStore>,
toolchain_store: Arc<dyn LanguageToolchainStore>,
environment: Entity<ProjectEnvironment>,
git_store: Entity<GitStore>,
cx: &mut Context<Self>,
) -> Self {
Self::Functional(StoreState {
@@ -172,6 +174,7 @@ impl TaskStore {
},
task_inventory: Inventory::new(cx),
buffer_store,
git_store,
toolchain_store,
worktree_store,
})
@@ -183,6 +186,7 @@ impl TaskStore {
toolchain_store: Arc<dyn LanguageToolchainStore>,
upstream_client: AnyProtoClient,
project_id: u64,
git_store: Entity<GitStore>,
cx: &mut Context<Self>,
) -> Self {
Self::Functional(StoreState {
@@ -192,6 +196,7 @@ impl TaskStore {
},
task_inventory: Inventory::new(cx),
buffer_store,
git_store,
toolchain_store,
worktree_store,
})
@@ -207,6 +212,7 @@ impl TaskStore {
TaskStore::Functional(state) => match &state.mode {
StoreMode::Local { environment, .. } => local_task_context_for_location(
state.worktree_store.clone(),
state.git_store.clone(),
state.toolchain_store.clone(),
environment.clone(),
captured_variables,
@@ -220,6 +226,7 @@ impl TaskStore {
*project_id,
upstream_client.clone(),
state.worktree_store.clone(),
state.git_store.clone(),
captured_variables,
location,
state.toolchain_store.clone(),
@@ -302,6 +309,7 @@ impl TaskStore {
fn local_task_context_for_location(
worktree_store: Entity<WorktreeStore>,
git_store: Entity<GitStore>,
toolchain_store: Arc<dyn LanguageToolchainStore>,
environment: Entity<ProjectEnvironment>,
captured_variables: TaskVariables,
@@ -329,7 +337,7 @@ fn local_task_context_for_location(
worktree_store.clone(),
location,
project_env.clone(),
BasicContextProvider::new(worktree_store),
BasicContextProvider::new(worktree_store, git_store),
toolchain_store,
cx,
)
@@ -351,6 +359,7 @@ fn remote_task_context_for_location(
project_id: u64,
upstream_client: AnyProtoClient,
worktree_store: Entity<WorktreeStore>,
git_store: Entity<GitStore>,
captured_variables: TaskVariables,
location: Location,
toolchain_store: Arc<dyn LanguageToolchainStore>,
@@ -362,7 +371,7 @@ fn remote_task_context_for_location(
.update(|cx| {
let worktree_root = worktree_root(&worktree_store, &location, cx);
BasicContextProvider::new(worktree_store).build_context(
BasicContextProvider::new(worktree_store, git_store).build_context(
&TaskVariables::default(),
ContextLocation {
fs: None,
@@ -23,6 +23,7 @@ mod go_locator {
show_summary: true,
show_command: true,
save: SaveStrategy::default(),
hooks: Default::default(),
};
let scenario = locator
@@ -51,6 +52,7 @@ mod go_locator {
show_summary: true,
show_command: true,
save: SaveStrategy::default(),
hooks: Default::default(),
};
let scenario = locator
@@ -190,6 +192,7 @@ mod go_locator {
show_summary: true,
show_command: true,
save: SaveStrategy::default(),
hooks: Default::default(),
};
let scenario = locator
@@ -225,6 +228,7 @@ mod python_locator {
show_summary: false,
show_command: false,
save: task::SaveStrategy::default(),
hooks: Default::default(),
};
let expected_scenario = DebugScenario {
@@ -191,6 +191,7 @@ impl HeadlessProject {
worktree_store.clone(),
toolchain_store.read(cx).as_language_toolchain_store(),
environment.clone(),
git_store.clone(),
cx,
);
task_store.shared(REMOTE_SERVER_PROJECT_ID, session.clone(), cx);
+1 -3
View File
@@ -1431,9 +1431,7 @@ impl std::fmt::Display for InvalidSettingsError {
| InvalidSettingsError::DefaultSettings { message }
| InvalidSettingsError::Tasks { message, .. }
| InvalidSettingsError::Editorconfig { message, .. }
| InvalidSettingsError::Debug { message, .. } => {
write!(f, "{message}")
}
| InvalidSettingsError::Debug { message, .. } => write!(f, "{message}"),
}
}
}
+8 -2
View File
@@ -23,8 +23,8 @@ pub use debug_format::{
Request, TcpArgumentsTemplate, ZedDebugConfig,
};
pub use task_template::{
DebugArgsRequest, HideStrategy, RevealStrategy, SaveStrategy, TaskTemplate, TaskTemplates,
substitute_variables_in_map, substitute_variables_in_str,
DebugArgsRequest, HideStrategy, RevealStrategy, SaveStrategy, TaskHook, TaskTemplate,
TaskTemplates, substitute_variables_in_map, substitute_variables_in_str,
};
pub use util::shell::{Shell, ShellKind};
pub use util::shell_builder::ShellBuilder;
@@ -181,6 +181,10 @@ pub enum VariableName {
/// Open a Picker to select a process ID to use in place
/// Can only be used to debug configurations
PickProcessId,
/// An absolute path of the main (original) git worktree for the current repository.
/// For normal checkouts, this equals the worktree root. For linked worktrees,
/// this is the original repo's working directory.
MainGitWorktree,
/// Custom variable, provided by the plugin or other external source.
/// Will be printed with `CUSTOM_` prefix to avoid potential conflicts with other variables.
Custom(Cow<'static, str>),
@@ -216,6 +220,7 @@ impl FromStr for VariableName {
"LANGUAGE" => Self::Language,
"ROW" => Self::Row,
"COLUMN" => Self::Column,
"MAIN_GIT_WORKTREE" => Self::MainGitWorktree,
_ => {
if let Some(custom_name) =
without_prefix.strip_prefix(ZED_CUSTOM_VARIABLE_NAME_PREFIX)
@@ -251,6 +256,7 @@ impl std::fmt::Display for VariableName {
Self::Language => write!(f, "{ZED_VARIABLE_NAME_PREFIX}LANGUAGE"),
Self::RunnableSymbol => write!(f, "{ZED_VARIABLE_NAME_PREFIX}RUNNABLE_SYMBOL"),
Self::PickProcessId => write!(f, "{ZED_VARIABLE_NAME_PREFIX}PICK_PID"),
Self::MainGitWorktree => write!(f, "{ZED_VARIABLE_NAME_PREFIX}MAIN_GIT_WORKTREE"),
Self::Custom(s) => write!(
f,
"{ZED_VARIABLE_NAME_PREFIX}{ZED_CUSTOM_VARIABLE_NAME_PREFIX}{s}"
+11
View File
@@ -75,6 +75,9 @@ pub struct TaskTemplate {
/// Which edited buffers to save before running the task.
#[serde(default)]
pub save: SaveStrategy,
/// Hooks that this task runs when emitted.
#[serde(default)]
pub hooks: HashSet<TaskHook>,
}
#[derive(Deserialize, Eq, PartialEq, Clone, Debug)]
@@ -86,6 +89,14 @@ pub enum DebugArgsRequest {
Attach(AttachRequest),
}
/// What to do with the terminal pane and tab, after the command was started.
#[derive(Clone, Copy, Debug, PartialEq, Hash, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum TaskHook {
#[serde(alias = "create_git_worktree")]
CreateWorktree,
}
/// What to do with the terminal pane and tab, after the command was started.
#[derive(Default, Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
+5 -1
View File
@@ -434,7 +434,9 @@ mod tests {
)
.await;
let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
let worktree_store = project.read_with(cx, |project, _| project.worktree_store());
let (worktree_store, git_store) = project.read_with(cx, |project, _| {
(project.worktree_store(), project.git_store().clone())
});
let rust_language = Arc::new(
Language::new(
LanguageConfig {
@@ -451,6 +453,7 @@ mod tests {
.unwrap()
.with_context_provider(Some(Arc::new(BasicContextProvider::new(
worktree_store.clone(),
git_store.clone(),
)))),
);
@@ -474,6 +477,7 @@ mod tests {
.unwrap()
.with_context_provider(Some(Arc::new(BasicContextProvider::new(
worktree_store.clone(),
git_store.clone(),
)))),
);
+107 -1
View File
@@ -1,13 +1,14 @@
use std::process::ExitStatus;
use anyhow::Result;
use collections::HashSet;
use gpui::{AppContext, Context, Entity, Task};
use language::Buffer;
use project::{TaskSourceKind, WorktreeId};
use remote::ConnectionState;
use task::{
DebugScenario, ResolvedTask, SaveStrategy, SharedTaskContext, SpawnInTerminal, TaskContext,
TaskTemplate,
TaskHook, TaskTemplate, TaskVariables, VariableName,
};
use ui::Window;
use util::TryFutureExt;
@@ -164,6 +165,111 @@ impl Workspace {
Task::ready(None)
}
}
pub fn run_create_worktree_tasks(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let project = self.project().clone();
let hooks = HashSet::from_iter([TaskHook::CreateWorktree]);
let worktree_tasks: Vec<(WorktreeId, TaskContext, Vec<TaskTemplate>)> = {
let project = project.read(cx);
let task_store = project.task_store();
let Some(inventory) = task_store.read(cx).task_inventory().cloned() else {
return;
};
let git_store = project.git_store().read(cx);
let mut worktree_tasks = Vec::new();
for worktree in project.worktrees(cx) {
let worktree = worktree.read(cx);
let worktree_id = worktree.id();
let worktree_abs_path = worktree.abs_path();
let templates: Vec<TaskTemplate> = inventory
.read(cx)
.templates_with_hooks(&hooks, worktree_id)
.into_iter()
.map(|(_, template)| template)
.collect();
if templates.is_empty() {
continue;
}
let mut task_variables = TaskVariables::default();
task_variables.insert(
VariableName::WorktreeRoot,
worktree_abs_path.to_string_lossy().into_owned(),
);
if let Some(path) = git_store.original_repo_path_for_worktree(worktree_id, cx) {
task_variables.insert(
VariableName::MainGitWorktree,
path.to_string_lossy().into_owned(),
);
}
let task_context = TaskContext {
cwd: Some(worktree_abs_path.to_path_buf()),
task_variables,
project_env: Default::default(),
};
worktree_tasks.push((worktree_id, task_context, templates));
}
worktree_tasks
};
if worktree_tasks.is_empty() {
return;
}
let task = cx.spawn_in(window, async move |workspace, cx| {
let mut tasks = Vec::new();
for (worktree_id, task_context, templates) in worktree_tasks {
let id_base = format!("worktree_setup_{worktree_id}");
tasks.push(cx.spawn({
let workspace = workspace.clone();
async move |cx| {
for task_template in templates {
let Some(resolved) =
task_template.resolve_task(&id_base, &task_context)
else {
continue;
};
let status = workspace.update_in(cx, |workspace, window, cx| {
workspace.spawn_in_terminal(resolved.resolved, window, cx)
})?;
if let Some(result) = status.await {
match result {
Ok(exit_status) if !exit_status.success() => {
log::error!(
"Git worktree setup task failed with status: {:?}",
exit_status.code()
);
break;
}
Err(error) => {
log::error!("Git worktree setup task error: {error:#}");
break;
}
_ => {}
}
}
}
anyhow::Ok(())
}
}));
}
futures::future::join_all(tasks).await;
anyhow::Ok(())
});
task.detach_and_log_err(cx);
}
}
#[cfg(test)]