diff --git a/Cargo.lock b/Cargo.lock index 564819fab8..781d79470f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7351,6 +7351,7 @@ dependencies = [ "serde_json", "settings", "smallvec", + "task", "theme", "theme_settings", "time", diff --git a/crates/git_graph/Cargo.toml b/crates/git_graph/Cargo.toml index 3d0180e306..43dcb88974 100644 --- a/crates/git_graph/Cargo.toml +++ b/crates/git_graph/Cargo.toml @@ -36,6 +36,7 @@ project_panel.workspace = true search.workspace = true settings.workspace = true smallvec.workspace = true +task.workspace = true theme.workspace = true theme_settings.workspace = true time.workspace = true diff --git a/crates/git_graph/src/git_graph.rs b/crates/git_graph/src/git_graph.rs index 4a09d69254..a772b8d585 100644 --- a/crates/git_graph/src/git_graph.rs +++ b/crates/git_graph/src/git_graph.rs @@ -22,7 +22,7 @@ use language::line_diff; use menu::{Cancel, SelectFirst, SelectLast, SelectNext, SelectPrevious}; use picker::{Picker, PickerDelegate}; use project::{ - ProjectPath, + GIT_COMMAND_TASK_TAG, ProjectPath, TaskSourceKind, git_store::{ CommitDataState, GitGraphEvent, GitStore, GitStoreEvent, GraphDataResponse, Repository, RepositoryEvent, RepositoryId, @@ -41,6 +41,7 @@ use std::{ sync::{Arc, OnceLock}, time::{Duration, Instant}, }; +use task::{ResolvedTask, TaskContext, TaskVariables, VariableName}; use theme::AccentColors; use time::{OffsetDateTime, UtcOffset, format_description::BorrowedFormatItem}; use ui::{ @@ -2031,6 +2032,82 @@ impl GitGraph { self.copy_commit_tag(selected_entry_index, window, cx); } + fn git_task_context(&self, commit_sha: Oid, cx: &App) -> Option { + let repository_path = self + .get_repository(cx)? + .read(cx) + .work_directory_abs_path + .to_path_buf(); + + let repository_name = repository_path + .file_name() + .and_then(|name| name.to_str()) + .map(ToString::to_string); + + let mut task_variables = TaskVariables::from_iter([ + (VariableName::GitSha, commit_sha.to_string()), + (VariableName::GitShaShort, commit_sha.display_short()), + ( + VariableName::GitRepositoryPath, + repository_path.to_string_lossy().into_owned(), + ), + ]); + + if let Some(repository_name) = repository_name { + task_variables.insert(VariableName::GitRepositoryName, repository_name); + } + + Some(TaskContext { + cwd: Some(repository_path), + task_variables, + ..TaskContext::default() + }) + } + + fn git_context_menu_tasks( + &self, + task_context: &TaskContext, + cx: &App, + ) -> Vec<(TaskSourceKind, ResolvedTask)> { + let Some(workspace) = self.workspace.upgrade() else { + return Vec::new(); + }; + + let project = workspace.read(cx).project().clone(); + + let task_inventory = project.read_with(cx, |project, cx| { + project.task_store().read(cx).task_inventory().cloned() + }); + + let Some(task_inventory) = task_inventory else { + return Vec::new(); + }; + + task_inventory + .read(cx) + .resolve_global_tasks_with_tag(GIT_COMMAND_TASK_TAG, task_context) + } + + fn schedule_git_task( + &mut self, + task_source_kind: TaskSourceKind, + resolved_task: ResolvedTask, + window: &mut Window, + cx: &mut Context, + ) { + self.workspace + .update(cx, |workspace, cx| { + workspace.schedule_resolved_task( + task_source_kind, + resolved_task, + false, + window, + cx, + ); + }) + .ok(); + } + fn deploy_entry_context_menu( &mut self, position: Point, @@ -2041,7 +2118,8 @@ impl GitGraph { let Some(commit) = self.graph_data.commits.get(index) else { return; }; - let short_sha = commit.data.sha.display_short(); + let sha = commit.data.sha; + let sha_short = sha.display_short(); let tag_names = commit.data.tag_names(); let copy_tag_label = "Copy Tag"; let copy_tag_label: SharedString = match tag_names.as_slice() { @@ -2050,13 +2128,17 @@ impl GitGraph { _ => format!("{copy_tag_label}…").into(), }; let copy_tag_disabled = tag_names.is_empty(); + let git_tasks = self + .git_task_context(sha, cx) + .map(|task_context| self.git_context_menu_tasks(&task_context, cx)) + .unwrap_or_default(); let focus_handle = self.focus_handle.clone(); let git_graph = cx.entity(); let context_menu = ContextMenu::build(window, cx, |context_menu, window, _| { context_menu .context(focus_handle) - .header(format!("Commit {short_sha}")) + .header(format!("Commit {sha_short}")) .entry( "View Commit", Some(OpenCommitView.boxed_clone()), @@ -2079,6 +2161,28 @@ impl GitGraph { this.copy_commit_tag(index, window, cx); })), ) + .when(!git_tasks.is_empty(), |mut menu| { + menu = menu.separator().header("Custom Git Commands"); + + for (task_source_kind, resolved_task) in git_tasks { + let label = resolved_task.display_label().to_string(); + + menu = menu.entry( + label, + None, + window.handler_for(&git_graph, move |this, window, cx| { + this.schedule_git_task( + task_source_kind.clone(), + resolved_task.clone(), + window, + cx, + ); + }), + ); + } + + menu + }) }); self.set_context_menu(context_menu, position, index, window, cx); } @@ -3883,8 +3987,8 @@ mod tests { use git::Oid; use git::repository::InitialGraphCommitData; use gpui::{TestAppContext, UpdateGlobal}; - use project::Project; use project::git_store::{GitStoreEvent, RepositoryEvent}; + use project::{Project, TaskSourceKind, task_store::TaskSettingsLocation}; use rand::prelude::*; use serde_json::json; use settings::{SettingsStore, ThemeSettingsContent}; @@ -5934,4 +6038,158 @@ mod tests { assert_eq!(graph.selected_entry_idx, Some(0)); }); } + + #[gpui::test] + async fn test_global_git_command_task_runs_from_context_menu(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + Path::new("/project"), + json!({ + ".git": {}, + "file.txt": "content", + }), + ) + .await; + + let commit_sha = Oid::try_from("abcdef1234567890abcdef1234567890abcdef12") + .expect("commit SHA should be valid"); + fs.set_graph_commits( + Path::new("/project/.git"), + vec![Arc::new(InitialGraphCommitData { + sha: commit_sha, + parents: SmallVec::new(), + ref_names: Vec::new(), + })], + ); + + let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; + cx.run_until_parked(); + + let repository = project.read_with(cx, |project, cx| { + project + .active_repository(cx) + .expect("project should have an active repository") + }); + let task_inventory = project.read_with(cx, |project, cx| { + project + .task_store() + .read(cx) + .task_inventory() + .cloned() + .expect("project should have a task inventory") + }); + + task_inventory.update(cx, |inventory, _| { + inventory + .update_file_based_tasks( + TaskSettingsLocation::Global(Path::new("/tasks.json")), + Some( + &serde_json::to_string(&json!([ + // Tagged global task that should be scheduled from the Git graph context menu. + { + "label": "Git Show $ZED_GIT_SHA_SHORT", + "command": "git", + "args": ["show", "$ZED_GIT_SHA"], + "cwd": "$ZED_GIT_REPOSITORY_PATH", + "env": { + "REPOSITORY": "$ZED_GIT_REPOSITORY_NAME", + }, + "tags": [GIT_COMMAND_TASK_TAG], + }, + // Untagged task that should not appear in the Git graph context menu. + { + "label": "Git Status", + "command": "git", + "args": ["status"], + }, + // Tagged task that still should not appear because Git graph task contexts + // do not provide editor-specific variables. + { + "label": "Print File $ZED_FILE", + "command": "echo", + "args": ["$ZED_FILE"], + "tags": [GIT_COMMAND_TASK_TAG], + }, + ])) + .expect("tasks JSON should serialize"), + ), + ) + .expect("tasks should parse"); + }); + + let (multi_workspace, cx) = cx.add_window_view(|window, cx| { + workspace::MultiWorkspace::test_new(project.clone(), window, cx) + }); + let workspace = multi_workspace.read_with(&*cx, |multi_workspace, _| { + multi_workspace.workspace().clone() + }); + let workspace_weak = workspace.downgrade(); + + let git_graph = cx.new_window_entity(|window, cx| { + GitGraph::new( + repository.read(cx).id, + project.read(cx).git_store().clone(), + workspace_weak, + None, + window, + cx, + ) + }); + workspace.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane(Box::new(git_graph.clone()), None, true, window, cx); + }); + cx.run_until_parked(); + + git_graph.update_in(cx, |git_graph, window, cx| { + assert_eq!(git_graph.graph_data.commits.len(), 1); + git_graph.deploy_entry_context_menu(point(px(20.), px(20.)), 0, window, cx); + }); + cx.run_until_parked(); + + let context_menu = git_graph.read_with(&*cx, |git_graph, _| { + git_graph + .context_menu + .as_ref() + .expect("context menu should be open") + .menu + .clone() + }); + context_menu.update_in(cx, |context_menu, window, cx| { + context_menu + .select_last(window, cx) + .expect("custom Git task should be selectable"); + context_menu.confirm(&menu::Confirm, window, cx); + }); + cx.run_until_parked(); + + let (task_source_kind, resolved_task) = task_inventory.read_with(&*cx, |inventory, _| { + inventory + .last_scheduled_task(None) + .expect("custom Git task should be scheduled") + }); + + assert!( + matches!(task_source_kind, TaskSourceKind::AbsPath { .. }), + "scheduled task should come from global tasks" + ); + assert_eq!(resolved_task.resolved_label, "Git Show abcdef1"); + assert_eq!(resolved_task.resolved.command, Some("git".to_string())); + assert_eq!( + resolved_task.resolved.args, + vec![ + "show".to_string(), + "abcdef1234567890abcdef1234567890abcdef12".to_string(), + ] + ); + assert_eq!( + resolved_task.resolved.cwd, + Some(Path::new("/project").to_path_buf()) + ); + assert_eq!( + resolved_task.resolved.env.get("REPOSITORY"), + Some(&"project".to_string()) + ); + } } diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index dde7d4f1b3..9e47bd6064 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -157,8 +157,8 @@ pub use prettier::FORMAT_SUFFIX as TEST_PRETTIER_FORMAT_SUFFIX; #[cfg(any(test, feature = "test-support"))] pub use prettier::RANGE_FORMAT_SUFFIX as TEST_PRETTIER_RANGE_FORMAT_SUFFIX; pub use task_inventory::{ - BasicContextProvider, ContextProviderWithTasks, DebugScenarioContext, Inventory, TaskContexts, - TaskSourceKind, + BasicContextProvider, ContextProviderWithTasks, DebugScenarioContext, GIT_COMMAND_TASK_TAG, + Inventory, TaskContexts, TaskSourceKind, }; pub use buffer_store::ProjectTransaction; diff --git a/crates/project/src/task_inventory.rs b/crates/project/src/task_inventory.rs index 6633801810..7124135167 100644 --- a/crates/project/src/task_inventory.rs +++ b/crates/project/src/task_inventory.rs @@ -30,6 +30,8 @@ use worktree::WorktreeId; use crate::{git_store::GitStore, task_store::TaskSettingsLocation, worktree_store::WorktreeStore}; +pub const GIT_COMMAND_TASK_TAG: &str = "git-command"; + #[derive(Clone, Debug, Default)] pub struct DebugScenarioContext { pub task_context: SharedTaskContext, @@ -644,6 +646,30 @@ impl Inventory { self.last_scheduled_tasks.retain(|(_, task)| &task.id != id); } + /// Returns global task templates with the provided tag. + pub fn global_templates_with_tag(&self, tag: &str) -> Vec<(TaskSourceKind, TaskTemplate)> { + self.global_templates_from_settings() + .filter(|(_, template)| template.tags.iter().any(|template_tag| template_tag == tag)) + .collect() + } + + /// Resolves global task templates with the provided tag against the provided task context. + pub fn resolve_global_tasks_with_tag( + &self, + tag: &str, + task_context: &TaskContext, + ) -> Vec<(TaskSourceKind, ResolvedTask)> { + self.global_templates_with_tag(tag) + .into_iter() + .filter_map(|(task_source_kind, task_template)| { + let id_base = task_source_kind.to_id_base(); + task_template + .resolve_task(&id_base, task_context) + .map(|resolved_task| (task_source_kind, resolved_task)) + }) + .collect() + } + /// Returns all task templates (worktree and global) that have at least one /// hook in the provided set. pub fn templates_with_hooks( diff --git a/crates/task/src/task.rs b/crates/task/src/task.rs index 5126d5e89f..a0e3f6f6e5 100644 --- a/crates/task/src/task.rs +++ b/crates/task/src/task.rs @@ -185,6 +185,14 @@ pub enum VariableName { /// For normal checkouts, this equals the worktree root. For linked worktrees, /// this is the original repo's working directory. MainGitWorktree, + /// Full SHA for the Git commit associated with the task context. + GitSha, + /// Short SHA for the Git commit associated with the task context. + GitShaShort, + /// Name of the Git repository associated with the task context. + GitRepositoryName, + /// Absolute path of the Git repository associated with the task context. + GitRepositoryPath, /// 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>), @@ -221,6 +229,10 @@ impl FromStr for VariableName { "ROW" => Self::Row, "COLUMN" => Self::Column, "MAIN_GIT_WORKTREE" => Self::MainGitWorktree, + "GIT_SHA" => Self::GitSha, + "GIT_SHA_SHORT" => Self::GitShaShort, + "GIT_REPOSITORY_NAME" => Self::GitRepositoryName, + "GIT_REPOSITORY_PATH" => Self::GitRepositoryPath, _ => { if let Some(custom_name) = without_prefix.strip_prefix(ZED_CUSTOM_VARIABLE_NAME_PREFIX) @@ -257,6 +269,10 @@ impl std::fmt::Display for VariableName { 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::GitSha => write!(f, "{ZED_VARIABLE_NAME_PREFIX}GIT_SHA"), + Self::GitShaShort => write!(f, "{ZED_VARIABLE_NAME_PREFIX}GIT_SHA_SHORT"), + Self::GitRepositoryName => write!(f, "{ZED_VARIABLE_NAME_PREFIX}GIT_REPOSITORY_NAME"), + Self::GitRepositoryPath => write!(f, "{ZED_VARIABLE_NAME_PREFIX}GIT_REPOSITORY_PATH"), Self::Custom(s) => write!( f, "{ZED_VARIABLE_NAME_PREFIX}{ZED_CUSTOM_VARIABLE_NAME_PREFIX}{s}" diff --git a/crates/task/src/task_template.rs b/crates/task/src/task_template.rs index 25fde261f1..dfd4459060 100644 --- a/crates/task/src/task_template.rs +++ b/crates/task/src/task_template.rs @@ -511,7 +511,10 @@ fn substitute_all_template_variables_in_map( #[cfg(test)] mod tests { - use std::{borrow::Cow, path::Path}; + use std::{ + borrow::Cow, + path::{Path, PathBuf}, + }; use crate::{TaskVariables, VariableName}; @@ -1102,4 +1105,50 @@ mod tests { }; assert!(task.unknown_variables().is_empty()); } + + #[test] + fn test_git_variables_resolution() { + let task = TaskTemplate { + label: "Show $ZED_GIT_SHA_SHORT in $ZED_GIT_REPOSITORY_NAME".to_string(), + command: "git".to_string(), + args: vec!["show".to_string(), "$ZED_GIT_SHA".to_string()], + cwd: Some("$ZED_GIT_REPOSITORY_PATH".to_string()), + env: HashMap::from_iter([("COMMIT".to_string(), "$ZED_GIT_SHA".to_string())]), + ..TaskTemplate::default() + }; + let sha = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string(); + let sha_short = "0123456".to_string(); + let repo_name = "zed".to_string(); + let repo_path = format!("/Users/example/{repo_name}"); + + let context = TaskContext { + task_variables: TaskVariables::from_iter([ + (VariableName::GitSha, sha.clone()), + (VariableName::GitShaShort, sha_short.clone()), + (VariableName::GitRepositoryName, repo_name.clone()), + (VariableName::GitRepositoryPath, repo_path.clone()), + ]), + ..TaskContext::default() + }; + + let task = task.resolve_task(TEST_ID_BASE, &context).unwrap(); + assert_eq!( + task.resolved_label, + format!("Show {sha_short} in {repo_name}") + ); + assert_eq!(task.resolved.command, Some("git".to_string())); + assert_eq!(task.resolved.args, vec!["show".to_string(), sha.clone()]); + assert_eq!(task.resolved.cwd, Some(PathBuf::from(repo_path))); + assert_eq!(task.resolved.env.get("COMMIT"), Some(&sha)); + + assert_substituted_variables( + &task, + vec![ + VariableName::GitSha, + VariableName::GitShaShort, + VariableName::GitRepositoryName, + VariableName::GitRepositoryPath, + ], + ); + } } diff --git a/docs/src/tasks.md b/docs/src/tasks.md index b1b872e176..889f475aa8 100644 --- a/docs/src/tasks.md +++ b/docs/src/tasks.md @@ -255,6 +255,34 @@ Hook tasks are resolved from the same global and worktree-local `tasks.json` fil Tasks that define `hooks` are still available from the task modal like any other task, so the same template can be reused for manual runs. +## Custom Git Commands + +The Git Graph supports running custom Git command tasks from the commit context menu. +To add a command, define a task in your global `tasks.json` file with the `git-command` tag (worktree-local tasks are not supported yet). +When shown from a commit's context menu, the task is resolved against the selected commit and repository, and runs from the selected repository root by default. + +Git Graph command tasks support the Git-specific task variables below. +These variables are provided only when resolving Git Graph command tasks. +Other task variables, such as `ZED_FILE`, `ZED_SELECTED_TEXT`, `ZED_WORKTREE_ROOT`, and `ZED_MAIN_GIT_WORKTREE`, are not provided to Git Graph command tasks unless they use default values. + +- `ZED_GIT_SHA`: full SHA of the selected commit. +- `ZED_GIT_SHA_SHORT`: short SHA of the selected commit. +- `ZED_GIT_REPOSITORY_NAME`: name of the selected Git repository. +- `ZED_GIT_REPOSITORY_PATH`: absolute path to the selected Git repository's working directory. + +For example: + +```json [tasks] +[ + { + "label": "Branches containing commit: $ZED_GIT_SHA_SHORT", + "command": "git", + "args": ["branch", "-a", "--contains", "$ZED_GIT_SHA"], + "tags": ["git-command"] + } +] +``` + ## VS Code Task Format When importing VS Code tasks from `.vscode/tasks.json`, you can omit the `label` field. Zed automatically generates labels based on the task type: