git_panel: Load template commit message (#42827)

Jack Jen , Christopher Biscardi , and Marshall Bowers created

Closes #41371

Release Notes:

- Adds loading commit message from template file set in `git config
commit.template`

---------

Co-authored-by: Christopher Biscardi <chris@christopherbiscardi.com>
Co-authored-by: Marshall Bowers <git@maxdeviant.com>

Change summary

crates/fs/src/fake_git_repo.rs  |  5 ++
crates/git/src/repository.rs    | 59 +++++++++++++++++++++++++++++++++++
crates/git_ui/src/git_panel.rs  | 38 +++++++++++++++++++++-
crates/project/src/git_store.rs | 20 ++++++++++-
4 files changed, 117 insertions(+), 5 deletions(-)

Detailed changes

crates/fs/src/fake_git_repo.rs 🔗

@@ -4,6 +4,7 @@ use crate::{FakeFs, FakeFsEntry, Fs, RemoveOptions, RenameOptions};
 use anyhow::{Context as _, Result, bail};
 use collections::{HashMap, HashSet};
 use futures::future::{self, BoxFuture, join_all};
+use git::repository::GitCommitTemplate;
 use git::{
     Oid, RunHook,
     blame::Blame,
@@ -171,6 +172,10 @@ impl GitRepository for FakeGitRepository {
         self.executor.spawn(async move { fut.await.ok() }).boxed()
     }
 
+    fn load_commit_template(&self) -> BoxFuture<'_, Result<Option<GitCommitTemplate>>> {
+        async { Ok(None) }.boxed()
+    }
+
     fn load_blob_content(&self, oid: git::Oid) -> BoxFuture<'_, Result<String>> {
         self.with_state_async(false, move |state| {
             state.oids.get(&oid).cloned().context("oid does not exist")

crates/git/src/repository.rs 🔗

@@ -983,6 +983,8 @@ pub trait GitRepository: Send + Sync {
         target_checkpoint: GitRepositoryCheckpoint,
     ) -> BoxFuture<'_, Result<String>>;
 
+    fn load_commit_template(&self) -> BoxFuture<'_, Result<Option<GitCommitTemplate>>>;
+
     fn default_branch(
         &self,
         include_remote_name: bool,
@@ -1146,6 +1148,11 @@ pub struct GitCommitter {
     pub email: Option<String>,
 }
 
+#[derive(Clone, Debug)]
+pub struct GitCommitTemplate {
+    pub template: String,
+}
+
 pub async fn get_git_committer(cx: &AsyncApp) -> GitCommitter {
     if cfg!(any(feature = "test-support", test)) {
         return GitCommitter {
@@ -1501,6 +1508,58 @@ impl GitRepository for RealGitRepository {
             .boxed()
     }
 
+    fn load_commit_template(&self) -> BoxFuture<'_, Result<Option<GitCommitTemplate>>> {
+        let working_directory_and_git_binary = self.working_directory().map(|working_directory| {
+            (
+                working_directory.clone(),
+                GitBinary::new(
+                    self.any_git_binary_path.clone(),
+                    working_directory,
+                    self.path(),
+                    self.executor.clone(),
+                    self.is_trusted(),
+                ),
+            )
+        });
+
+        self.executor
+            .spawn(async move {
+                let (working_directory, git_binary) = working_directory_and_git_binary?;
+
+                let output = git_binary
+                    .build_command(&["config", "--get", "commit.template"])
+                    .output()
+                    .await
+                    .context("failed to run git config --get commit.template")?;
+
+                let raw_path = String::from_utf8_lossy(&output.stdout).trim().to_string();
+                if !output.status.success() || raw_path.is_empty() {
+                    return Ok(None);
+                }
+
+                let path = PathBuf::from(&raw_path);
+                let path = if let Some(path) = raw_path.strip_prefix("~/") {
+                    paths::home_dir().join(path)
+                } else if path.is_relative() {
+                    working_directory.join(path)
+                } else {
+                    path
+                };
+
+                let template = match std::fs::read_to_string(&path) {
+                    Ok(s) if !s.trim().is_empty() => Some(s),
+                    Err(err) => {
+                        log::warn!("failed to read commit template {}: {}", path.display(), err);
+                        None
+                    }
+                    _ => None,
+                };
+
+                Ok(template.map(|template| GitCommitTemplate { template }))
+            })
+            .boxed()
+    }
+
     fn set_index_text(
         &self,
         path: RepoPath,

crates/git_ui/src/git_panel.rs 🔗

@@ -25,8 +25,8 @@ use file_icons::FileIcons;
 use futures::StreamExt as _;
 use git::commit::ParsedCommitMessage;
 use git::repository::{
-    Branch, CommitDetails, CommitOptions, CommitSummary, DiffType, FetchOptions, GitCommitter,
-    PushOptions, Remote, RemoteCommandOutput, ResetMode, Upstream, UpstreamTracking,
+    Branch, CommitDetails, CommitOptions, CommitSummary, DiffType, FetchOptions, GitCommitTemplate,
+    GitCommitter, PushOptions, Remote, RemoteCommandOutput, ResetMode, Upstream, UpstreamTracking,
     UpstreamTrackingStatus, get_git_committer,
 };
 use git::stash::GitStash;
@@ -652,6 +652,7 @@ pub struct GitPanel {
     show_placeholders: bool,
     local_committer: Option<GitCommitter>,
     local_committer_task: Option<Task<()>>,
+    commit_template: Option<GitCommitTemplate>,
     bulk_staging: Option<BulkStaging>,
     stash_entries: GitStash,
 
@@ -835,6 +836,7 @@ impl GitPanel {
                 show_placeholders: false,
                 local_committer: None,
                 local_committer_task: None,
+                commit_template: None,
                 context_menu: None,
                 workspace: workspace.weak_handle(),
                 modal_open: false,
@@ -3483,10 +3485,29 @@ impl GitPanel {
                 cx,
             )
         });
+        let load_template = self.load_commit_template(cx);
 
         cx.spawn_in(window, async move |git_panel, cx| {
             let buffer = load_buffer.await?;
+            let template = load_template.await?;
+
             git_panel.update_in(cx, |git_panel, window, cx| {
+                git_panel.commit_template = template;
+                if buffer.read(cx).text().trim().is_empty() {
+                    let template_text = git_panel
+                        .commit_template
+                        .as_ref()
+                        .map(|t| t.template.clone())
+                        .unwrap_or_default();
+                    if !template_text.is_empty() {
+                        buffer.update(cx, |buffer, cx| {
+                            let start = buffer.anchor_before(0);
+                            let end = buffer.anchor_after(buffer.len());
+                            buffer.edit([(start..end, template_text)], None, cx);
+                        });
+                    }
+                }
+
                 if git_panel
                     .commit_editor
                     .read(cx)
@@ -5508,6 +5529,19 @@ impl GitPanel {
         !self.project.read(cx).is_read_only(cx)
     }
 
+    pub fn load_commit_template(
+        &self,
+        cx: &mut Context<Self>,
+    ) -> Task<anyhow::Result<Option<GitCommitTemplate>>> {
+        let Some(repo) = self.active_repository.clone() else {
+            return Task::ready(Err(anyhow::anyhow!("no active repo")));
+        };
+        repo.update(cx, |repo, cx| {
+            let rx = repo.load_commit_template_text();
+            cx.spawn(async move |_, _| rx.await?)
+        })
+    }
+
     pub fn amend_pending(&self) -> bool {
         self.amend_pending
     }

crates/project/src/git_store.rs 🔗

@@ -33,9 +33,10 @@ use git::{
     parse_git_remote_url,
     repository::{
         Branch, CommitDetails, CommitDiff, CommitFile, CommitOptions, CreateWorktreeTarget,
-        DiffType, FetchOptions, GitRepository, GitRepositoryCheckpoint, GraphCommitData,
-        InitialGraphCommitData, LogOrder, LogSource, PushOptions, Remote, RemoteCommandOutput,
-        RepoPath, ResetMode, SearchCommitArgs, UpstreamTrackingStatus, Worktree as GitWorktree,
+        DiffType, FetchOptions, GitCommitTemplate, GitRepository, GitRepositoryCheckpoint,
+        GraphCommitData, InitialGraphCommitData, LogOrder, LogSource, PushOptions, Remote,
+        RemoteCommandOutput, RepoPath, ResetMode, SearchCommitArgs, UpstreamTrackingStatus,
+        Worktree as GitWorktree,
     },
     stash::{GitStash, StashEntry},
     status::{
@@ -7060,6 +7061,19 @@ impl Repository {
         cx.spawn(|_: &mut AsyncApp| async move { rx.await? })
     }
 
+    pub fn load_commit_template_text(
+        &mut self,
+    ) -> oneshot::Receiver<Result<Option<GitCommitTemplate>>> {
+        self.send_job(None, move |git_repo, _cx| async move {
+            match git_repo {
+                RepositoryState::Local(LocalRepositoryState { backend, .. }) => {
+                    backend.load_commit_template().await
+                }
+                RepositoryState::Remote(_) => Ok(None),
+            }
+        })
+    }
+
     fn load_blob_content(&mut self, oid: Oid, cx: &App) -> Task<Result<String>> {
         let repository_id = self.snapshot.id;
         let rx = self.send_job(None, move |state, _| async move {