git: Add support for git pull with rebase (#41117)

Sathiyaraman M created

- Adds a new action `git::PullRebase` which adds `--rebase` in the final
command invoked by existing Git-Pull implementation.
- Includes the new action in "Fetch/Push" button in the Git Panel
(screenshot below)
- Adds key-binding for `git::PullRebase` in all three platforms,
following the existing key-binding patterns (`ctrl-g shift-down`)
- Update git docs to include the new action.

Sidenote: This is my first ever OSS contribution

Screenshot:

<img width="234" height="215" alt="image"
src="https://github.com/user-attachments/assets/713d068f-5ea5-444f-8d66-444ca65affc8"
/>

---

Release Notes:

- Git: Added `git: pull rebase` for running `git pull --rebase`.

Change summary

assets/keymaps/default-linux.json   |  1 
assets/keymaps/default-macos.json   |  1 
assets/keymaps/default-windows.json |  1 
crates/fs/src/fake_git_repo.rs      |  1 
crates/git/src/git.rs               |  2 
crates/git/src/repository.rs        | 10 +++
crates/git_ui/src/git_panel.rs      |  3 
crates/git_ui/src/git_ui.rs         | 11 +++
crates/project/src/git_store.rs     | 91 ++++++++++++++++--------------
crates/proto/proto/git.proto        |  1 
docs/src/git.md                     |  1 
11 files changed, 78 insertions(+), 45 deletions(-)

Detailed changes

assets/keymaps/default-linux.json 🔗

@@ -945,6 +945,7 @@
       "ctrl-g ctrl-g": "git::Fetch",
       "ctrl-g up": "git::Push",
       "ctrl-g down": "git::Pull",
+      "ctrl-g shift-down": "git::PullRebase",
       "ctrl-g shift-up": "git::ForcePush",
       "ctrl-g d": "git::Diff",
       "ctrl-g backspace": "git::RestoreTrackedFiles",

assets/keymaps/default-macos.json 🔗

@@ -1038,6 +1038,7 @@
       "ctrl-g ctrl-g": "git::Fetch",
       "ctrl-g up": "git::Push",
       "ctrl-g down": "git::Pull",
+      "ctrl-g shift-down": "git::PullRebase",
       "ctrl-g shift-up": "git::ForcePush",
       "ctrl-g d": "git::Diff",
       "ctrl-g backspace": "git::RestoreTrackedFiles",

assets/keymaps/default-windows.json 🔗

@@ -954,6 +954,7 @@
       "ctrl-g ctrl-g": "git::Fetch",
       "ctrl-g up": "git::Push",
       "ctrl-g down": "git::Pull",
+      "ctrl-g shift-down": "git::PullRebase",
       "ctrl-g shift-up": "git::ForcePush",
       "ctrl-g d": "git::Diff",
       "ctrl-g backspace": "git::RestoreTrackedFiles",

crates/fs/src/fake_git_repo.rs 🔗

@@ -543,6 +543,7 @@ impl GitRepository for FakeGitRepository {
         &self,
         _branch: String,
         _remote: String,
+        _rebase: bool,
         _askpass: AskPassDelegate,
         _env: Arc<HashMap<String, String>>,
         _cx: AsyncApp,

crates/git/src/git.rs 🔗

@@ -72,6 +72,8 @@ actions!(
         ForcePush,
         /// Pulls changes from the remote repository.
         Pull,
+        /// Pulls changes from the remote repository with rebase.
+        PullRebase,
         /// Fetches changes from the remote repository.
         Fetch,
         /// Fetches changes from a specific remote.

crates/git/src/repository.rs 🔗

@@ -533,6 +533,7 @@ pub trait GitRepository: Send + Sync {
         &self,
         branch_name: String,
         upstream_name: String,
+        rebase: bool,
         askpass: AskPassDelegate,
         env: Arc<HashMap<String, String>>,
         // This method takes an AsyncApp to ensure it's invoked on the main thread,
@@ -1690,6 +1691,7 @@ impl GitRepository for RealGitRepository {
         &self,
         branch_name: String,
         remote_name: String,
+        rebase: bool,
         ask_pass: AskPassDelegate,
         env: Arc<HashMap<String, String>>,
         cx: AsyncApp,
@@ -1703,7 +1705,13 @@ impl GitRepository for RealGitRepository {
             command
                 .envs(env.iter())
                 .current_dir(&working_directory?)
-                .args(["pull"])
+                .arg("pull");
+
+            if rebase {
+                command.arg("--rebase");
+            }
+
+            command
                 .arg(remote_name)
                 .arg(branch_name)
                 .stdout(smol::process::Stdio::piped())

crates/git_ui/src/git_panel.rs 🔗

@@ -2219,7 +2219,7 @@ impl GitPanel {
         .detach();
     }
 
-    pub(crate) fn pull(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+    pub(crate) fn pull(&mut self, rebase: bool, window: &mut Window, cx: &mut Context<Self>) {
         if !self.can_push_and_pull(cx) {
             return;
         }
@@ -2254,6 +2254,7 @@ impl GitPanel {
                 repo.pull(
                     branch.name().to_owned().into(),
                     remote.name.clone(),
+                    rebase,
                     askpass,
                     cx,
                 )

crates/git_ui/src/git_ui.rs 🔗

@@ -126,7 +126,15 @@ pub fn init(cx: &mut App) {
                     return;
                 };
                 panel.update(cx, |panel, cx| {
-                    panel.pull(window, cx);
+                    panel.pull(false, window, cx);
+                });
+            });
+            workspace.register_action(|workspace, _: &git::PullRebase, window, cx| {
+                let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
+                    return;
+                };
+                panel.update(cx, |panel, cx| {
+                    panel.pull(true, window, cx);
                 });
             });
         }
@@ -597,6 +605,7 @@ mod remote_button {
                         .action("Fetch", git::Fetch.boxed_clone())
                         .action("Fetch From", git::FetchFrom.boxed_clone())
                         .action("Pull", git::Pull.boxed_clone())
+                        .action("Pull (Rebase)", git::PullRebase.boxed_clone())
                         .separator()
                         .action("Push", git::Push.boxed_clone())
                         .action("Push To", git::PushTo.boxed_clone())

crates/project/src/git_store.rs 🔗

@@ -1725,10 +1725,11 @@ impl GitStore {
 
         let branch_name = envelope.payload.branch_name.into();
         let remote_name = envelope.payload.remote_name.into();
+        let rebase = envelope.payload.rebase;
 
         let remote_message = repository_handle
             .update(&mut cx, |repository_handle, cx| {
-                repository_handle.pull(branch_name, remote_name, askpass, cx)
+                repository_handle.pull(branch_name, remote_name, rebase, askpass, cx)
             })?
             .await??;
 
@@ -4294,6 +4295,7 @@ impl Repository {
         &mut self,
         branch: SharedString,
         remote: SharedString,
+        rebase: bool,
         askpass: AskPassDelegate,
         _cx: &mut App,
     ) -> oneshot::Receiver<Result<RemoteCommandOutput>> {
@@ -4301,50 +4303,55 @@ impl Repository {
         let askpass_id = util::post_inc(&mut self.latest_askpass_id);
         let id = self.id;
 
-        self.send_job(
-            Some(format!("git pull {} {}", remote, branch).into()),
-            move |git_repo, cx| async move {
-                match git_repo {
-                    RepositoryState::Local {
-                        backend,
-                        environment,
-                        ..
-                    } => {
-                        backend
-                            .pull(
-                                branch.to_string(),
-                                remote.to_string(),
-                                askpass,
-                                environment.clone(),
-                                cx,
-                            )
-                            .await
-                    }
-                    RepositoryState::Remote { project_id, client } => {
-                        askpass_delegates.lock().insert(askpass_id, askpass);
-                        let _defer = util::defer(|| {
-                            let askpass_delegate = askpass_delegates.lock().remove(&askpass_id);
-                            debug_assert!(askpass_delegate.is_some());
-                        });
-                        let response = client
-                            .request(proto::Pull {
-                                project_id: project_id.0,
-                                repository_id: id.to_proto(),
-                                askpass_id,
-                                branch_name: branch.to_string(),
-                                remote_name: remote.to_string(),
-                            })
-                            .await
-                            .context("sending pull request")?;
+        let status = if rebase {
+            Some(format!("git pull --rebase {} {}", remote, branch).into())
+        } else {
+            Some(format!("git pull {} {}", remote, branch).into())
+        };
 
-                        Ok(RemoteCommandOutput {
-                            stdout: response.stdout,
-                            stderr: response.stderr,
+        self.send_job(status, move |git_repo, cx| async move {
+            match git_repo {
+                RepositoryState::Local {
+                    backend,
+                    environment,
+                    ..
+                } => {
+                    backend
+                        .pull(
+                            branch.to_string(),
+                            remote.to_string(),
+                            rebase,
+                            askpass,
+                            environment.clone(),
+                            cx,
+                        )
+                        .await
+                }
+                RepositoryState::Remote { project_id, client } => {
+                    askpass_delegates.lock().insert(askpass_id, askpass);
+                    let _defer = util::defer(|| {
+                        let askpass_delegate = askpass_delegates.lock().remove(&askpass_id);
+                        debug_assert!(askpass_delegate.is_some());
+                    });
+                    let response = client
+                        .request(proto::Pull {
+                            project_id: project_id.0,
+                            repository_id: id.to_proto(),
+                            askpass_id,
+                            rebase,
+                            branch_name: branch.to_string(),
+                            remote_name: remote.to_string(),
                         })
-                    }
+                        .await
+                        .context("sending pull request")?;
+
+                    Ok(RemoteCommandOutput {
+                        stdout: response.stdout,
+                        stderr: response.stderr,
+                    })
                 }
-            },
-        )
+            }
+        })
     }
 
     fn spawn_set_index_text_job(

crates/proto/proto/git.proto 🔗

@@ -405,6 +405,7 @@ message Pull {
     string remote_name = 4;
     string branch_name = 5;
     uint64 askpass_id = 6;
+    bool rebase = 7;
 }
 
 message RemoteMessageResponse {

docs/src/git.md 🔗

@@ -179,6 +179,7 @@ When viewing files with changes, Zed displays diff hunks that can be expanded or
 | {#action git::Push}                       | {#kb git::Push}                       |
 | {#action git::ForcePush}                  | {#kb git::ForcePush}                  |
 | {#action git::Pull}                       | {#kb git::Pull}                       |
+| {#action git::PullRebase}                 | {#kb git::PullRebase}                 |
 | {#action git::Fetch}                      | {#kb git::Fetch}                      |
 | {#action git::Diff}                       | {#kb git::Diff}                       |
 | {#action git::Restore}                    | {#kb git::Restore}                    |