Use `git config --global user.email` for email address in automatic `Co-authored-by` (#32624)

Michael Sloan , Conrad Irwin , and Conrad created

Release Notes:

- Automatic population of `Co-authored-by` now uses `git config --global
user.email`

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
Co-authored-by: Conrad <conrad@zed.dev>

Change summary

Cargo.lock                                                                |  1 
crates/channel/src/channel_store_tests.rs                                 |  3 
crates/client/src/client.rs                                               | 12 
crates/client/src/user.rs                                                 |  6 
crates/collab/migrations.sqlite/20221109000000_test_schema.sql            |  4 
crates/collab/migrations/20250612153105_add_collaborator_commit_email.sql |  4 
crates/collab/src/db.rs                                                   |  4 
crates/collab/src/db/queries/buffers.rs                                   |  8 
crates/collab/src/db/queries/channels.rs                                  |  1 
crates/collab/src/db/queries/projects.rs                                  | 36 
crates/collab/src/db/queries/rooms.rs                                     |  4 
crates/collab/src/db/tables/project_collaborator.rs                       |  2 
crates/collab/src/db/tests/buffer_tests.rs                                |  4 
crates/collab/src/rpc.rs                                                  | 32 
crates/collab/src/tests/following_tests.rs                                |  2 
crates/collab/src/tests/integration_tests.rs                              |  2 
crates/collab_ui/src/chat_panel.rs                                        |  3 
crates/git/src/repository.rs                                              | 46 
crates/git_ui/Cargo.toml                                                  |  1 
crates/git_ui/src/commit_modal.rs                                         |  1 
crates/git_ui/src/git_panel.rs                                            | 71 
crates/project/src/project.rs                                             |  4 
crates/proto/proto/call.proto                                             |  2 
crates/proto/proto/core.proto                                             |  4 
24 files changed, 188 insertions(+), 69 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -6162,6 +6162,7 @@ dependencies = [
  "anyhow",
  "askpass",
  "buffer_diff",
+ "call",
  "chrono",
  "collections",
  "command_palette_hooks",

crates/channel/src/channel_store_tests.rs 🔗

@@ -269,7 +269,6 @@ async fn test_channel_messages(cx: &mut TestAppContext) {
                 github_login: "nathansobo".into(),
                 avatar_url: "http://avatar.com/nathansobo".into(),
                 name: None,
-                email: None,
             }],
         },
     );
@@ -323,7 +322,6 @@ async fn test_channel_messages(cx: &mut TestAppContext) {
                 github_login: "maxbrunsfeld".into(),
                 avatar_url: "http://avatar.com/maxbrunsfeld".into(),
                 name: None,
-                email: None,
             }],
         },
     );
@@ -368,7 +366,6 @@ async fn test_channel_messages(cx: &mut TestAppContext) {
                 github_login: "as-cii".into(),
                 avatar_url: "http://avatar.com/as-cii".into(),
                 name: None,
-                email: None,
             }],
         },
     );

crates/client/src/client.rs 🔗

@@ -1887,8 +1887,16 @@ mod tests {
             .set_entity(&entity3, &mut cx.to_async());
         drop(subscription3);
 
-        server.send(proto::JoinProject { project_id: 1 });
-        server.send(proto::JoinProject { project_id: 2 });
+        server.send(proto::JoinProject {
+            project_id: 1,
+            committer_name: None,
+            committer_email: None,
+        });
+        server.send(proto::JoinProject {
+            project_id: 2,
+            committer_name: None,
+            committer_email: None,
+        });
         done_rx1.recv().await.unwrap();
         done_rx2.recv().await.unwrap();
     }

crates/client/src/user.rs 🔗

@@ -49,7 +49,6 @@ pub struct User {
     pub github_login: String,
     pub avatar_uri: SharedUri,
     pub name: Option<String>,
-    pub email: Option<String>,
 }
 
 #[derive(Clone, Debug, PartialEq, Eq)]
@@ -58,6 +57,8 @@ pub struct Collaborator {
     pub replica_id: ReplicaId,
     pub user_id: UserId,
     pub is_host: bool,
+    pub committer_name: Option<String>,
+    pub committer_email: Option<String>,
 }
 
 impl PartialOrd for User {
@@ -881,7 +882,6 @@ impl User {
             github_login: message.github_login,
             avatar_uri: message.avatar_url.into(),
             name: message.name,
-            email: message.email,
         })
     }
 }
@@ -912,6 +912,8 @@ impl Collaborator {
             replica_id: message.replica_id as ReplicaId,
             user_id: message.user_id as UserId,
             is_host: message.is_host,
+            committer_name: message.committer_name,
+            committer_email: message.committer_email,
         })
     }
 }

crates/collab/migrations.sqlite/20221109000000_test_schema.sql 🔗

@@ -185,7 +185,9 @@ CREATE TABLE "project_collaborators" (
     "connection_server_id" INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE,
     "user_id" INTEGER NOT NULL,
     "replica_id" INTEGER NOT NULL,
-    "is_host" BOOLEAN NOT NULL
+    "is_host" BOOLEAN NOT NULL,
+    "committer_name" VARCHAR,
+    "committer_email" VARCHAR
 );
 
 CREATE INDEX "index_project_collaborators_on_project_id" ON "project_collaborators" ("project_id");

crates/collab/src/db.rs 🔗

@@ -751,6 +751,8 @@ pub struct ProjectCollaborator {
     pub user_id: UserId,
     pub replica_id: ReplicaId,
     pub is_host: bool,
+    pub committer_name: Option<String>,
+    pub committer_email: Option<String>,
 }
 
 impl ProjectCollaborator {
@@ -760,6 +762,8 @@ impl ProjectCollaborator {
             replica_id: self.replica_id.0 as u32,
             user_id: self.user_id.to_proto(),
             is_host: self.is_host,
+            committer_name: self.committer_name.clone(),
+            committer_email: self.committer_email.clone(),
         }
     }
 }

crates/collab/src/db/queries/buffers.rs 🔗

@@ -118,6 +118,8 @@ impl Database {
                         user_id: collaborator.user_id.to_proto(),
                         replica_id: collaborator.replica_id.0 as u32,
                         is_host: false,
+                        committer_name: None,
+                        committer_email: None,
                     })
                     .collect(),
             })
@@ -225,6 +227,8 @@ impl Database {
                                 user_id: collaborator.user_id.to_proto(),
                                 replica_id: collaborator.replica_id.0 as u32,
                                 is_host: false,
+                                committer_name: None,
+                                committer_email: None,
                             })
                             .collect(),
                     },
@@ -261,6 +265,8 @@ impl Database {
                         replica_id: db_collaborator.replica_id.0 as u32,
                         user_id: db_collaborator.user_id.to_proto(),
                         is_host: false,
+                        committer_name: None,
+                        committer_email: None,
                     })
                 } else {
                     collaborator_ids_to_remove.push(db_collaborator.id);
@@ -390,6 +396,8 @@ impl Database {
                 replica_id: row.replica_id.0 as u32,
                 user_id: row.user_id.to_proto(),
                 is_host: false,
+                committer_name: None,
+                committer_email: None,
             });
         }
 

crates/collab/src/db/queries/projects.rs 🔗

@@ -98,7 +98,9 @@ impl Database {
                 user_id: ActiveValue::set(participant.user_id),
                 replica_id: ActiveValue::set(ReplicaId(replica_id)),
                 is_host: ActiveValue::set(true),
-                ..Default::default()
+                id: ActiveValue::NotSet,
+                committer_name: ActiveValue::Set(None),
+                committer_email: ActiveValue::Set(None),
             }
             .insert(&*tx)
             .await?;
@@ -784,13 +786,27 @@ impl Database {
         project_id: ProjectId,
         connection: ConnectionId,
         user_id: UserId,
+        committer_name: Option<String>,
+        committer_email: Option<String>,
     ) -> Result<TransactionGuard<(Project, ReplicaId)>> {
-        self.project_transaction(project_id, |tx| async move {
-            let (project, role) = self
-                .access_project(project_id, connection, Capability::ReadOnly, &tx)
-                .await?;
-            self.join_project_internal(project, user_id, connection, role, &tx)
+        self.project_transaction(project_id, move |tx| {
+            let committer_name = committer_name.clone();
+            let committer_email = committer_email.clone();
+            async move {
+                let (project, role) = self
+                    .access_project(project_id, connection, Capability::ReadOnly, &tx)
+                    .await?;
+                self.join_project_internal(
+                    project,
+                    user_id,
+                    committer_name,
+                    committer_email,
+                    connection,
+                    role,
+                    &tx,
+                )
                 .await
+            }
         })
         .await
     }
@@ -799,6 +815,8 @@ impl Database {
         &self,
         project: project::Model,
         user_id: UserId,
+        committer_name: Option<String>,
+        committer_email: Option<String>,
         connection: ConnectionId,
         role: ChannelRole,
         tx: &DatabaseTransaction,
@@ -822,7 +840,9 @@ impl Database {
             user_id: ActiveValue::set(user_id),
             replica_id: ActiveValue::set(replica_id),
             is_host: ActiveValue::set(false),
-            ..Default::default()
+            id: ActiveValue::NotSet,
+            committer_name: ActiveValue::set(committer_name),
+            committer_email: ActiveValue::set(committer_email),
         }
         .insert(tx)
         .await?;
@@ -1026,6 +1046,8 @@ impl Database {
                     user_id: collaborator.user_id,
                     replica_id: collaborator.replica_id,
                     is_host: collaborator.is_host,
+                    committer_name: collaborator.committer_name,
+                    committer_email: collaborator.committer_email,
                 })
                 .collect(),
             worktrees,

crates/collab/src/db/queries/rooms.rs 🔗

@@ -553,6 +553,8 @@ impl Database {
                             user_id: collaborator.user_id,
                             replica_id: collaborator.replica_id,
                             is_host: collaborator.is_host,
+                            committer_name: collaborator.committer_name.clone(),
+                            committer_email: collaborator.committer_email.clone(),
                         })
                         .collect(),
                     worktrees: reshared_project.worktrees.clone(),
@@ -857,6 +859,8 @@ impl Database {
                 user_id: collaborator.user_id,
                 replica_id: collaborator.replica_id,
                 is_host: collaborator.is_host,
+                committer_name: collaborator.committer_name,
+                committer_email: collaborator.committer_email,
             })
             .collect::<Vec<_>>();
 

crates/collab/src/db/tests/buffer_tests.rs 🔗

@@ -126,12 +126,16 @@ async fn test_channel_buffers(db: &Arc<Database>) {
                 peer_id: Some(rpc::proto::PeerId { id: 1, owner_id }),
                 replica_id: 0,
                 is_host: false,
+                committer_name: None,
+                committer_email: None,
             },
             rpc::proto::Collaborator {
                 user_id: b_id.to_proto(),
                 peer_id: Some(rpc::proto::PeerId { id: 2, owner_id }),
                 replica_id: 1,
                 is_host: false,
+                committer_name: None,
+                committer_email: None,
             }
         ]
     );

crates/collab/src/rpc.rs 🔗

@@ -14,7 +14,7 @@ use crate::{
     db::{
         self, BufferId, Capability, Channel, ChannelId, ChannelRole, ChannelsForUser,
         CreatedChannelMessage, Database, InviteMemberResult, MembershipUpdated, MessageId,
-        NotificationId, Project, ProjectId, RejoinedProject, RemoveChannelMemberResult, ReplicaId,
+        NotificationId, ProjectId, RejoinedProject, RemoveChannelMemberResult,
         RespondToChannelInvite, RoomId, ServerId, UpdatedChannelMessage, User, UserId,
     },
     executor::Executor,
@@ -1890,28 +1890,16 @@ async fn join_project(
 
     let db = session.db().await;
     let (project, replica_id) = &mut *db
-        .join_project(project_id, session.connection_id, session.user_id())
+        .join_project(
+            project_id,
+            session.connection_id,
+            session.user_id(),
+            request.committer_name.clone(),
+            request.committer_email.clone(),
+        )
         .await?;
     drop(db);
     tracing::info!(%project_id, "join remote project");
-    join_project_internal(response, session, project, replica_id)
-}
-
-trait JoinProjectInternalResponse {
-    fn send(self, result: proto::JoinProjectResponse) -> Result<()>;
-}
-impl JoinProjectInternalResponse for Response<proto::JoinProject> {
-    fn send(self, result: proto::JoinProjectResponse) -> Result<()> {
-        Response::<proto::JoinProject>::send(self, result)
-    }
-}
-
-fn join_project_internal(
-    response: impl JoinProjectInternalResponse,
-    session: Session,
-    project: &mut Project,
-    replica_id: &ReplicaId,
-) -> Result<()> {
     let collaborators = project
         .collaborators
         .iter()
@@ -1939,6 +1927,8 @@ fn join_project_internal(
             replica_id: replica_id.0 as u32,
             user_id: guest_user_id.to_proto(),
             is_host: false,
+            committer_name: request.committer_name.clone(),
+            committer_email: request.committer_email.clone(),
         }),
     };
 
@@ -2567,7 +2557,6 @@ async fn get_users(
             id: user.id.to_proto(),
             avatar_url: format!("https://github.com/{}.png?size=128", user.github_login),
             github_login: user.github_login,
-            email: user.email_address,
             name: user.name,
         })
         .collect();
@@ -2601,7 +2590,6 @@ async fn fuzzy_search_users(
             avatar_url: format!("https://github.com/{}.png?size=128", user.github_login),
             github_login: user.github_login,
             name: user.name,
-            email: user.email_address,
         })
         .collect();
     response.send(proto::UsersResponse { users })?;

crates/collab/src/tests/following_tests.rs 🔗

@@ -1610,6 +1610,8 @@ async fn test_following_across_workspaces(cx_a: &mut TestAppContext, cx_b: &mut
         .root(cx_a)
         .unwrap();
 
+    executor.run_until_parked();
+
     workspace_a_project_b.update(cx_a2, |workspace, cx| {
         assert_eq!(workspace.project().read(cx).remote_id(), Some(project_b_id));
         assert!(workspace.is_being_followed(client_b.peer_id().unwrap()));

crates/collab/src/tests/integration_tests.rs 🔗

@@ -1876,7 +1876,6 @@ async fn test_active_call_events(
                 github_login: "user_a".to_string(),
                 avatar_uri: "avatar_a".into(),
                 name: None,
-                email: None,
             }),
             project_id: project_a_id,
             worktree_root_names: vec!["a".to_string()],
@@ -1896,7 +1895,6 @@ async fn test_active_call_events(
                 github_login: "user_b".to_string(),
                 avatar_uri: "avatar_b".into(),
                 name: None,
-                email: None,
             }),
             project_id: project_b_id,
             worktree_root_names: vec!["b".to_string()]

crates/collab_ui/src/chat_panel.rs 🔗

@@ -1218,7 +1218,6 @@ mod tests {
                 avatar_uri: "avatar_fgh".into(),
                 id: 103,
                 name: None,
-                email: None,
             }),
             nonce: 5,
             mentions: vec![(ranges[0].clone(), 101), (ranges[1].clone(), 102)],
@@ -1274,7 +1273,6 @@ mod tests {
                 avatar_uri: "avatar_fgh".into(),
                 id: 103,
                 name: None,
-                email: None,
             }),
             nonce: 5,
             mentions: Vec::new(),
@@ -1323,7 +1321,6 @@ mod tests {
                 avatar_uri: "avatar_fgh".into(),
                 id: 103,
                 name: None,
-                email: None,
             }),
             nonce: 5,
             mentions: Vec::new(),

crates/git/src/repository.rs 🔗

@@ -26,8 +26,8 @@ use std::{
 };
 use sum_tree::MapSeekTarget;
 use thiserror::Error;
-use util::ResultExt;
 use util::command::{new_smol_command, new_std_command};
+use util::{ResultExt, paths};
 use uuid::Uuid;
 
 pub use askpass::{AskPassDelegate, AskPassResult, AskPassSession};
@@ -508,6 +508,50 @@ pub struct GitRepositoryCheckpoint {
     pub commit_sha: Oid,
 }
 
+#[derive(Debug)]
+pub struct GitCommitter {
+    pub name: Option<String>,
+    pub email: Option<String>,
+}
+
+pub async fn get_git_committer(cx: &AsyncApp) -> GitCommitter {
+    if cfg!(any(feature = "test-support", test)) {
+        return GitCommitter {
+            name: None,
+            email: None,
+        };
+    }
+
+    let git_binary_path =
+        if cfg!(target_os = "macos") && option_env!("ZED_BUNDLE").as_deref() == Some("true") {
+            cx.update(|cx| {
+                cx.path_for_auxiliary_executable("git")
+                    .context("could not find git binary path")
+                    .log_err()
+            })
+            .ok()
+            .flatten()
+        } else {
+            None
+        };
+
+    let git = GitBinary::new(
+        git_binary_path.unwrap_or(PathBuf::from("git")),
+        paths::home_dir().clone(),
+        cx.background_executor().clone(),
+    );
+
+    cx.background_spawn(async move {
+        let name = git.run(["config", "--global", "user.name"]).await.log_err();
+        let email = git
+            .run(["config", "--global", "user.email"])
+            .await
+            .log_err();
+        GitCommitter { name, email }
+    })
+    .await
+}
+
 impl GitRepository for RealGitRepository {
     fn reload_index(&self) {
         if let Ok(mut index) = self.repository.lock().index() {

crates/git_ui/Cargo.toml 🔗

@@ -21,6 +21,7 @@ agent_settings.workspace = true
 anyhow.workspace = true
 askpass.workspace = true
 buffer_diff.workspace = true
+call.workspace = true
 chrono.workspace = true
 collections.workspace = true
 command_palette_hooks.workspace = true

crates/git_ui/src/commit_modal.rs 🔗

@@ -148,6 +148,7 @@ impl CommitModal {
                 }
             }
             git_panel.set_modal_open(true, cx);
+            git_panel.load_local_committer(cx);
         });
 
         let dock = workspace.dock_at_position(git_panel.position(window, cx));

crates/git_ui/src/git_panel.rs 🔗

@@ -20,8 +20,9 @@ use editor::{
 use futures::StreamExt as _;
 use git::blame::ParsedCommitMessage;
 use git::repository::{
-    Branch, CommitDetails, CommitOptions, CommitSummary, DiffType, FetchOptions, PushOptions,
-    Remote, RemoteCommandOutput, ResetMode, Upstream, UpstreamTracking, UpstreamTrackingStatus,
+    Branch, CommitDetails, CommitOptions, CommitSummary, DiffType, FetchOptions, GitCommitter,
+    PushOptions, Remote, RemoteCommandOutput, ResetMode, Upstream, UpstreamTracking,
+    UpstreamTrackingStatus, get_git_committer,
 };
 use git::status::StageStatus;
 use git::{Amend, ToggleStaged, repository::RepoPath, status::FileStatus};
@@ -358,6 +359,8 @@ pub struct GitPanel {
     context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
     modal_open: bool,
     show_placeholders: bool,
+    local_committer: Option<GitCommitter>,
+    local_committer_task: Option<Task<()>>,
     _settings_subscription: Subscription,
 }
 
@@ -520,6 +523,8 @@ impl GitPanel {
                 update_visible_entries_task: Task::ready(()),
                 width: None,
                 show_placeholders: false,
+                local_committer: None,
+                local_committer_task: None,
                 context_menu: None,
                 workspace: workspace.weak_handle(),
                 modal_open: false,
@@ -2250,6 +2255,19 @@ impl GitPanel {
         }
     }
 
+    pub fn load_local_committer(&mut self, cx: &Context<Self>) {
+        if self.local_committer_task.is_none() {
+            self.local_committer_task = Some(cx.spawn(async move |this, cx| {
+                let committer = get_git_committer(cx).await;
+                this.update(cx, |this, cx| {
+                    this.local_committer = Some(committer);
+                    cx.notify()
+                })
+                .ok();
+            }));
+        }
+    }
+
     fn potential_co_authors(&self, cx: &App) -> Vec<(String, String)> {
         let mut new_co_authors = Vec::new();
         let project = self.project.read(cx);
@@ -2272,34 +2290,38 @@ impl GitPanel {
             let Some(participant) = room.remote_participant_for_peer_id(*peer_id) else {
                 continue;
             };
-            if participant.can_write() && participant.user.email.is_some() {
-                let email = participant.user.email.clone().unwrap();
-
-                new_co_authors.push((
-                    participant
-                        .user
-                        .name
-                        .clone()
-                        .unwrap_or_else(|| participant.user.github_login.clone()),
-                    email,
-                ))
+            if !participant.can_write() {
+                continue;
+            }
+            if let Some(email) = &collaborator.committer_email {
+                let name = collaborator
+                    .committer_name
+                    .clone()
+                    .or_else(|| participant.user.name.clone())
+                    .unwrap_or_else(|| participant.user.github_login.clone());
+                new_co_authors.push((name.clone(), email.clone()))
             }
         }
         if !project.is_local() && !project.is_read_only(cx) {
-            if let Some(user) = room.local_participant_user(cx) {
-                if let Some(email) = user.email.clone() {
-                    new_co_authors.push((
-                        user.name
-                            .clone()
-                            .unwrap_or_else(|| user.github_login.clone()),
-                        email.clone(),
-                    ))
-                }
+            if let Some(local_committer) = self.local_committer(room, cx) {
+                new_co_authors.push(local_committer);
             }
         }
         new_co_authors
     }
 
+    fn local_committer(&self, room: &call::Room, cx: &App) -> Option<(String, String)> {
+        let user = room.local_participant_user(cx)?;
+        let committer = self.local_committer.as_ref()?;
+        let email = committer.email.clone()?;
+        let name = committer
+            .name
+            .clone()
+            .or_else(|| user.name.clone())
+            .unwrap_or_else(|| user.github_login.clone());
+        Some((name, email))
+    }
+
     fn toggle_fill_co_authors(
         &mut self,
         _: &ToggleFillCoAuthors,
@@ -4244,8 +4266,9 @@ impl Render for GitPanel {
         let has_write_access = self.has_write_access(cx);
 
         let has_co_authors = room.map_or(false, |room| {
-            room.read(cx)
-                .remote_participants()
+            self.load_local_committer(cx);
+            let room = room.read(cx);
+            room.remote_participants()
                 .values()
                 .any(|remote_participant| remote_participant.can_write())
         });

crates/project/src/project.rs 🔗

@@ -26,6 +26,7 @@ mod environment;
 use buffer_diff::BufferDiff;
 use context_server_store::ContextServerStore;
 pub use environment::{EnvironmentErrorMessage, ProjectEnvironmentEvent};
+use git::repository::get_git_committer;
 use git_store::{Repository, RepositoryId};
 pub mod search_history;
 mod yarn;
@@ -1323,9 +1324,12 @@ impl Project {
             ),
             EntitySubscription::DapStore(client.subscribe_to_entity::<DapStore>(remote_id)?),
         ];
+        let committer = get_git_committer(&cx).await;
         let response = client
             .request_envelope(proto::JoinProject {
                 project_id: remote_id,
+                committer_email: committer.email,
+                committer_name: committer.name,
             })
             .await?;
         Self::from_join_project_response(

crates/proto/proto/call.proto 🔗

@@ -189,6 +189,8 @@ message UpdateProject {
 
 message JoinProject {
     uint64 project_id = 1;
+    optional string committer_email = 2;
+    optional string committer_name = 3;
 }
 
 message JoinProjectResponse {

crates/proto/proto/core.proto 🔗

@@ -7,10 +7,10 @@ message PeerId {
 }
 
 message User {
+    reserved 4;
     uint64 id = 1;
     string github_login = 2;
     string avatar_url = 3;
-    optional string email = 4;
     optional string name = 5;
 }
 
@@ -24,4 +24,6 @@ message Collaborator {
     uint32 replica_id = 2;
     uint64 user_id = 3;
     bool is_host = 4;
+    optional string committer_name = 5;
+    optional string committer_email = 6;
 }