Merge pull request #1632 from zed-industries/git-gutter

Julia created

Tracking PR: Git gutter

Change summary

Cargo.lock                                |  52 +++
assets/settings/default.json              |   9 
crates/collab/Cargo.toml                  |   5 
crates/collab/src/integration_tests.rs    | 131 +++++++++
crates/collab/src/rpc.rs                  |  16 +
crates/editor/Cargo.toml                  |   1 
crates/editor/src/display_map/fold_map.rs |   1 
crates/editor/src/element.rs              | 169 ++++++++++
crates/editor/src/items.rs                |  11 
crates/editor/src/multi_buffer.rs         |  39 ++
crates/git/Cargo.toml                     |  28 +
crates/git/src/diff.rs                    | 354 ++++++++++++++++++++++++
crates/git/src/git.rs                     |  12 
crates/git/src/repository.rs              |  61 ++++
crates/language/Cargo.toml                |   1 
crates/language/src/buffer.rs             |  99 ++++++
crates/project/Cargo.toml                 |   1 
crates/project/src/fs.rs                  |  61 ++++
crates/project/src/project.rs             |  90 ++++++
crates/project/src/worktree.rs            | 357 +++++++++++++++++++++++-
crates/rpc/proto/zed.proto                |  11 
crates/rpc/src/proto.rs                   |   2 
crates/rpc/src/rpc.rs                     |   2 
crates/settings/src/settings.rs           |  33 ++
crates/sum_tree/src/sum_tree.rs           |   6 
crates/text/src/anchor.rs                 |   2 
crates/text/src/rope.rs                   |   7 
crates/theme/src/theme.rs                 |  13 
crates/util/Cargo.toml                    |   6 
crates/util/src/test.rs                   |  11 
crates/workspace/src/workspace.rs         | 152 ++++++++-
styles/src/styleTree/editor.ts            |  11 
styles/src/themes/common/base16.ts        |   5 
styles/src/themes/common/theme.ts         |   1 
34 files changed, 1,687 insertions(+), 73 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -1031,6 +1031,7 @@ dependencies = [
  "env_logger",
  "envy",
  "futures",
+ "git",
  "gpui",
  "hyper",
  "language",
@@ -1061,6 +1062,7 @@ dependencies = [
  "tracing",
  "tracing-log",
  "tracing-subscriber",
+ "unindent",
  "util",
  "workspace",
 ]
@@ -1697,6 +1699,7 @@ dependencies = [
  "env_logger",
  "futures",
  "fuzzy",
+ "git",
  "gpui",
  "indoc",
  "itertools",
@@ -2224,6 +2227,39 @@ dependencies = [
  "stable_deref_trait",
 ]
 
+[[package]]
+name = "git"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "async-trait",
+ "clock",
+ "collections",
+ "futures",
+ "git2",
+ "lazy_static",
+ "log",
+ "parking_lot 0.11.2",
+ "smol",
+ "sum_tree",
+ "text",
+ "unindent",
+ "util",
+]
+
+[[package]]
+name = "git2"
+version = "0.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2994bee4a3a6a51eb90c218523be382fd7ea09b16380b9312e9dbe955ff7c7d1"
+dependencies = [
+ "bitflags",
+ "libc",
+ "libgit2-sys",
+ "log",
+ "url",
+]
+
 [[package]]
 name = "glob"
 version = "0.3.0"
@@ -2840,6 +2876,7 @@ dependencies = [
  "env_logger",
  "futures",
  "fuzzy",
+ "git",
  "gpui",
  "lazy_static",
  "log",
@@ -2894,6 +2931,18 @@ version = "0.2.126"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "349d5a591cd28b49e1d1037471617a32ddcda5731b99419008085f72d5a53836"
 
+[[package]]
+name = "libgit2-sys"
+version = "0.14.0+1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47a00859c70c8a4f7218e6d1cc32875c4b55f6799445b842b0d8ed5e4c3d959b"
+dependencies = [
+ "cc",
+ "libc",
+ "libz-sys",
+ "pkg-config",
+]
+
 [[package]]
 name = "libloading"
 version = "0.7.3"
@@ -3970,6 +4019,7 @@ dependencies = [
  "fsevent",
  "futures",
  "fuzzy",
+ "git",
  "gpui",
  "ignore",
  "language",
@@ -6332,6 +6382,8 @@ version = "0.1.0"
 dependencies = [
  "anyhow",
  "futures",
+ "git2",
+ "lazy_static",
  "log",
  "rand 0.8.5",
  "serde_json",

assets/settings/default.json 🔗

@@ -74,6 +74,15 @@
     "hard_tabs": false,
     // How many columns a tab should occupy.
     "tab_size": 4,
+    // Git gutter behavior configuration.
+    "git": {
+        // Control whether the git gutter is shown. May take 2 values:
+        // 1. Show the gutter
+        //      "git_gutter": "tracked_files"
+        // 2. Hide the gutter
+        //      "git_gutter": "hide"
+        "git_gutter": "tracked_files"
+    },
     // Settings specific to the terminal
     "terminal": {
         // What shell to use when opening a terminal. May take 3 values: 

crates/collab/Cargo.toml 🔗

@@ -1,5 +1,5 @@
 [package]
-authors = ["Nathan Sobo <nathan@warp.dev>"]
+authors = ["Nathan Sobo <nathan@zed.dev>"]
 default-run = "collab"
 edition = "2021"
 name = "collab"
@@ -26,6 +26,7 @@ base64 = "0.13"
 clap = { version = "3.1", features = ["derive"], optional = true }
 envy = "0.4.2"
 futures = "0.3"
+git = { path = "../git" }
 hyper = "0.14"
 lazy_static = "1.4"
 lipsum = { version = "0.8", optional = true }
@@ -65,11 +66,13 @@ project = { path = "../project", features = ["test-support"] }
 settings = { path = "../settings", features = ["test-support"] }
 theme = { path = "../theme" }
 workspace = { path = "../workspace", features = ["test-support"] }
+git = { path = "../git", features = ["test-support"] }
 ctor = "0.1"
 env_logger = "0.9"
 util = { path = "../util" }
 lazy_static = "1.4"
 serde_json = { version = "1.0", features = ["preserve_order"] }
+unindent = "0.1"
 
 [features]
 seed-support = ["clap", "lipsum", "reqwest"]

crates/collab/src/integration_tests.rs 🔗

@@ -51,6 +51,7 @@ use std::{
     time::Duration,
 };
 use theme::ThemeRegistry;
+use unindent::Unindent as _;
 use workspace::{Item, SplitDirection, ToggleFollow, Workspace};
 
 #[ctor::ctor]
@@ -946,6 +947,136 @@ async fn test_propagate_saves_and_fs_changes(
         .await;
 }
 
+#[gpui::test(iterations = 10)]
+async fn test_git_diff_base_change(
+    executor: Arc<Deterministic>,
+    cx_a: &mut TestAppContext,
+    cx_b: &mut TestAppContext,
+) {
+    executor.forbid_parking();
+    let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
+    let client_a = server.create_client(cx_a, "user_a").await;
+    let client_b = server.create_client(cx_b, "user_b").await;
+    server
+        .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)])
+        .await;
+
+    client_a
+        .fs
+        .insert_tree(
+            "/dir",
+            json!({
+            ".git": {
+            },
+            "a.txt": "
+                    one
+                    two
+                    three
+                ".unindent(),
+            }),
+        )
+        .await;
+
+    let diff_base = "
+        one
+        three
+    "
+    .unindent();
+
+    let new_diff_base = "
+        one
+        two
+    "
+    .unindent();
+
+    client_a
+        .fs
+        .as_fake()
+        .set_index_for_repo(
+            Path::new("/dir/.git"),
+            &[(Path::new("a.txt"), diff_base.clone())],
+        )
+        .await;
+
+    let (project_a, worktree_id) = client_a.build_local_project("/dir", cx_a).await;
+    let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await;
+
+    // Create the buffer
+    let buffer_a = project_a
+        .update(cx_a, |p, cx| p.open_buffer((worktree_id, "/dir/a.txt"), cx))
+        .await
+        .unwrap();
+
+    // Wait for it to catch up to the new diff
+    executor.run_until_parked();
+
+    // Smoke test diffing
+    buffer_a.read_with(cx_a, |buffer, _| {
+        assert_eq!(buffer.diff_base(), Some(diff_base.as_ref()));
+        git::diff::assert_hunks(
+            buffer.snapshot().git_diff_hunks_in_range(0..4),
+            &buffer,
+            &diff_base,
+            &[(1..2, "", "two\n")],
+        );
+    });
+
+    // Create remote buffer
+    let buffer_b = project_b
+        .update(cx_b, |p, cx| p.open_buffer((worktree_id, "/dir/a.txt"), cx))
+        .await
+        .unwrap();
+
+    // Wait remote buffer to catch up to the new diff
+    executor.run_until_parked();
+
+    // Smoke test diffing
+    buffer_b.read_with(cx_b, |buffer, _| {
+        assert_eq!(buffer.diff_base(), Some(diff_base.as_ref()));
+        git::diff::assert_hunks(
+            buffer.snapshot().git_diff_hunks_in_range(0..4),
+            &buffer,
+            &diff_base,
+            &[(1..2, "", "two\n")],
+        );
+    });
+
+    client_a
+        .fs
+        .as_fake()
+        .set_index_for_repo(
+            Path::new("/dir/.git"),
+            &[(Path::new("a.txt"), new_diff_base.clone())],
+        )
+        .await;
+
+    // Wait for buffer_a to receive it
+    executor.run_until_parked();
+
+    // Smoke test new diffing
+    buffer_a.read_with(cx_a, |buffer, _| {
+        assert_eq!(buffer.diff_base(), Some(new_diff_base.as_ref()));
+
+        git::diff::assert_hunks(
+            buffer.snapshot().git_diff_hunks_in_range(0..4),
+            &buffer,
+            &diff_base,
+            &[(2..3, "", "three\n")],
+        );
+    });
+
+    // Smoke test B
+    buffer_b.read_with(cx_b, |buffer, _| {
+        assert_eq!(buffer.diff_base(), Some(new_diff_base.as_ref()));
+        git::diff::assert_hunks(
+            buffer.snapshot().git_diff_hunks_in_range(0..4),
+            &buffer,
+            &diff_base,
+            &[(2..3, "", "three\n")],
+        );
+    });
+}
+
 #[gpui::test(iterations = 10)]
 async fn test_fs_operations(
     executor: Arc<Deterministic>,

crates/collab/src/rpc.rs 🔗

@@ -206,6 +206,7 @@ impl Server {
             .add_message_handler(Server::unfollow)
             .add_message_handler(Server::update_followers)
             .add_request_handler(Server::get_channel_messages)
+            .add_message_handler(Server::update_diff_base)
             .add_request_handler(Server::get_private_user_info);
 
         Arc::new(server)
@@ -1728,6 +1729,21 @@ impl Server {
         Ok(())
     }
 
+    async fn update_diff_base(
+        self: Arc<Server>,
+        request: TypedEnvelope<proto::UpdateDiffBase>,
+    ) -> Result<()> {
+        let receiver_ids = self.store().await.project_connection_ids(
+            ProjectId::from_proto(request.payload.project_id),
+            request.sender_id,
+        )?;
+        broadcast(request.sender_id, receiver_ids, |connection_id| {
+            self.peer
+                .forward_send(request.sender_id, connection_id, request.payload.clone())
+        });
+        Ok(())
+    }
+
     async fn get_private_user_info(
         self: Arc<Self>,
         request: TypedEnvelope<proto::GetPrivateUserInfo>,

crates/editor/Cargo.toml 🔗

@@ -25,6 +25,7 @@ clock = { path = "../clock" }
 collections = { path = "../collections" }
 context_menu = { path = "../context_menu" }
 fuzzy = { path = "../fuzzy" }
+git = { path = "../git" }
 gpui = { path = "../gpui" }
 language = { path = "../language" }
 lsp = { path = "../lsp" }

crates/editor/src/display_map/fold_map.rs 🔗

@@ -274,6 +274,7 @@ impl FoldMap {
             if buffer.edit_count() != new_buffer.edit_count()
                 || buffer.parse_count() != new_buffer.parse_count()
                 || buffer.diagnostics_update_count() != new_buffer.diagnostics_update_count()
+                || buffer.git_diff_update_count() != new_buffer.git_diff_update_count()
                 || buffer.trailing_excerpt_update_count()
                     != new_buffer.trailing_excerpt_update_count()
             {

crates/editor/src/element.rs 🔗

@@ -16,6 +16,7 @@ use crate::{
 };
 use clock::ReplicaId;
 use collections::{BTreeMap, HashMap};
+use git::diff::{DiffHunk, DiffHunkStatus};
 use gpui::{
     color::Color,
     elements::*,
@@ -36,7 +37,7 @@ use gpui::{
 use json::json;
 use language::{Bias, DiagnosticSeverity, OffsetUtf16, Selection};
 use project::ProjectPath;
-use settings::Settings;
+use settings::{GitGutter, Settings};
 use smallvec::SmallVec;
 use std::{
     cmp::{self, Ordering},
@@ -45,6 +46,7 @@ use std::{
     ops::Range,
     sync::Arc,
 };
+use theme::DiffStyle;
 
 struct SelectionLayout {
     head: DisplayPoint,
@@ -524,30 +526,143 @@ impl EditorElement {
         layout: &mut LayoutState,
         cx: &mut PaintContext,
     ) {
-        let scroll_top =
-            layout.position_map.snapshot.scroll_position().y() * layout.position_map.line_height;
+        struct GutterLayout {
+            line_height: f32,
+            // scroll_position: Vector2F,
+            scroll_top: f32,
+            bounds: RectF,
+        }
+
+        struct DiffLayout<'a> {
+            buffer_row: u32,
+            last_diff: Option<&'a DiffHunk<u32>>,
+        }
+
+        fn diff_quad(
+            hunk: &DiffHunk<u32>,
+            gutter_layout: &GutterLayout,
+            diff_style: &DiffStyle,
+        ) -> Quad {
+            let color = match hunk.status() {
+                DiffHunkStatus::Added => diff_style.inserted,
+                DiffHunkStatus::Modified => diff_style.modified,
+
+                //TODO: This rendering is entirely a horrible hack
+                DiffHunkStatus::Removed => {
+                    let row = hunk.buffer_range.start;
+
+                    let offset = gutter_layout.line_height / 2.;
+                    let start_y =
+                        row as f32 * gutter_layout.line_height + offset - gutter_layout.scroll_top;
+                    let end_y = start_y + gutter_layout.line_height;
+
+                    let width = diff_style.removed_width_em * gutter_layout.line_height;
+                    let highlight_origin = gutter_layout.bounds.origin() + vec2f(-width, start_y);
+                    let highlight_size = vec2f(width * 2., end_y - start_y);
+                    let highlight_bounds = RectF::new(highlight_origin, highlight_size);
+
+                    return Quad {
+                        bounds: highlight_bounds,
+                        background: Some(diff_style.deleted),
+                        border: Border::new(0., Color::transparent_black()),
+                        corner_radius: 1. * gutter_layout.line_height,
+                    };
+                }
+            };
+
+            let start_row = hunk.buffer_range.start;
+            let end_row = hunk.buffer_range.end;
+
+            let start_y = start_row as f32 * gutter_layout.line_height - gutter_layout.scroll_top;
+            let end_y = end_row as f32 * gutter_layout.line_height - gutter_layout.scroll_top;
+
+            let width = diff_style.width_em * gutter_layout.line_height;
+            let highlight_origin = gutter_layout.bounds.origin() + vec2f(-width, start_y);
+            let highlight_size = vec2f(width * 2., end_y - start_y);
+            let highlight_bounds = RectF::new(highlight_origin, highlight_size);
+
+            Quad {
+                bounds: highlight_bounds,
+                background: Some(color),
+                border: Border::new(0., Color::transparent_black()),
+                corner_radius: diff_style.corner_radius * gutter_layout.line_height,
+            }
+        }
+
+        let scroll_position = layout.position_map.snapshot.scroll_position();
+        let gutter_layout = {
+            let line_height = layout.position_map.line_height;
+            GutterLayout {
+                scroll_top: scroll_position.y() * line_height,
+                line_height,
+                bounds,
+            }
+        };
+
+        let mut diff_layout = DiffLayout {
+            buffer_row: scroll_position.y() as u32,
+            last_diff: None,
+        };
+
+        let diff_style = &cx.global::<Settings>().theme.editor.diff.clone();
+        let show_gutter = matches!(
+            &cx.global::<Settings>()
+                .git_overrides
+                .git_gutter
+                .unwrap_or_default(),
+            GitGutter::TrackedFiles
+        );
+
+        // line is `None` when there's a line wrap
         for (ix, line) in layout.line_number_layouts.iter().enumerate() {
             if let Some(line) = line {
                 let line_origin = bounds.origin()
                     + vec2f(
                         bounds.width() - line.width() - layout.gutter_padding,
-                        ix as f32 * layout.position_map.line_height
-                            - (scroll_top % layout.position_map.line_height),
+                        ix as f32 * gutter_layout.line_height
+                            - (gutter_layout.scroll_top % gutter_layout.line_height),
                     );
-                line.paint(
-                    line_origin,
-                    visible_bounds,
-                    layout.position_map.line_height,
-                    cx,
-                );
+
+                line.paint(line_origin, visible_bounds, gutter_layout.line_height, cx);
+
+                if show_gutter {
+                    //This line starts a buffer line, so let's do the diff calculation
+                    let new_hunk = get_hunk(diff_layout.buffer_row, &layout.diff_hunks);
+
+                    let (is_ending, is_starting) = match (diff_layout.last_diff, new_hunk) {
+                        (Some(old_hunk), Some(new_hunk)) if new_hunk == old_hunk => {
+                            (false, false)
+                        }
+                        (a, b) => (a.is_some(), b.is_some()),
+                    };
+
+                    if is_ending {
+                        let last_hunk = diff_layout.last_diff.take().unwrap();
+                        cx.scene
+                            .push_quad(diff_quad(last_hunk, &gutter_layout, diff_style));
+                    }
+
+                    if is_starting {
+                        let new_hunk = new_hunk.unwrap();
+                        diff_layout.last_diff = Some(new_hunk);
+                    };
+
+                    diff_layout.buffer_row += 1;
+                }
             }
         }
 
+        // If we ran out with a diff hunk still being prepped, paint it now
+        if let Some(last_hunk) = diff_layout.last_diff {
+            cx.scene
+                .push_quad(diff_quad(last_hunk, &gutter_layout, diff_style))
+        }
+
         if let Some((row, indicator)) = layout.code_actions_indicator.as_mut() {
             let mut x = bounds.width() - layout.gutter_padding;
-            let mut y = *row as f32 * layout.position_map.line_height - scroll_top;
+            let mut y = *row as f32 * gutter_layout.line_height - gutter_layout.scroll_top;
             x += ((layout.gutter_padding + layout.gutter_margin) - indicator.size().x()) / 2.;
-            y += (layout.position_map.line_height - indicator.size().y()) / 2.;
+            y += (gutter_layout.line_height - indicator.size().y()) / 2.;
             indicator.paint(bounds.origin() + vec2f(x, y), visible_bounds, cx);
         }
     }
@@ -1252,6 +1367,27 @@ impl EditorElement {
     }
 }
 
+/// Get the hunk that contains buffer_line, starting from start_idx
+/// Returns none if there is none found, and
+fn get_hunk(buffer_line: u32, hunks: &[DiffHunk<u32>]) -> Option<&DiffHunk<u32>> {
+    for i in 0..hunks.len() {
+        // Safety: Index out of bounds is handled by the check above
+        let hunk = hunks.get(i).unwrap();
+        if hunk.buffer_range.contains(&(buffer_line as u32)) {
+            return Some(hunk);
+        } else if hunk.status() == DiffHunkStatus::Removed && buffer_line == hunk.buffer_range.start
+        {
+            return Some(hunk);
+        } else if hunk.buffer_range.start > buffer_line as u32 {
+            // If we've passed the buffer_line, just stop
+            return None;
+        }
+    }
+
+    // We reached the end of the array without finding a hunk, just return none.
+    return None;
+}
+
 impl Element for EditorElement {
     type LayoutState = LayoutState;
     type PaintState = ();
@@ -1425,6 +1561,11 @@ impl Element for EditorElement {
         let line_number_layouts =
             self.layout_line_numbers(start_row..end_row, &active_rows, &snapshot, cx);
 
+        let diff_hunks = snapshot
+            .buffer_snapshot
+            .git_diff_hunks_in_range(start_row..end_row)
+            .collect();
+
         let mut max_visible_line_width = 0.0;
         let line_layouts = self.layout_lines(start_row..end_row, &snapshot, cx);
         for line in &line_layouts {
@@ -1573,6 +1714,7 @@ impl Element for EditorElement {
                 highlighted_rows,
                 highlighted_ranges,
                 line_number_layouts,
+                diff_hunks,
                 blocks,
                 selections,
                 context_menu,
@@ -1710,6 +1852,7 @@ pub struct LayoutState {
     highlighted_ranges: Vec<(Range<DisplayPoint>, Color)>,
     selections: Vec<(ReplicaId, Vec<SelectionLayout>)>,
     context_menu: Option<(DisplayPoint, ElementBox)>,
+    diff_hunks: Vec<DiffHunk<u32>>,
     code_actions_indicator: Option<(u32, ElementBox)>,
     hover_popovers: Option<(DisplayPoint, Vec<ElementBox>)>,
 }

crates/editor/src/items.rs 🔗

@@ -478,6 +478,17 @@ impl Item for Editor {
         })
     }
 
+    fn git_diff_recalc(
+        &mut self,
+        _project: ModelHandle<Project>,
+        cx: &mut ViewContext<Self>,
+    ) -> Task<Result<()>> {
+        self.buffer().update(cx, |multibuffer, cx| {
+            multibuffer.git_diff_recalc(cx);
+        });
+        Task::ready(Ok(()))
+    }
+
     fn to_item_events(event: &Self::Event) -> Vec<workspace::ItemEvent> {
         let mut result = Vec::new();
         match event {

crates/editor/src/multi_buffer.rs 🔗

@@ -4,6 +4,7 @@ pub use anchor::{Anchor, AnchorRangeExt};
 use anyhow::Result;
 use clock::ReplicaId;
 use collections::{BTreeMap, Bound, HashMap, HashSet};
+use git::diff::DiffHunk;
 use gpui::{AppContext, Entity, ModelContext, ModelHandle, Task};
 pub use language::Completion;
 use language::{
@@ -90,6 +91,7 @@ struct BufferState {
     last_selections_update_count: usize,
     last_diagnostics_update_count: usize,
     last_file_update_count: usize,
+    last_git_diff_update_count: usize,
     excerpts: Vec<ExcerptId>,
     _subscriptions: [gpui::Subscription; 2],
 }
@@ -101,6 +103,7 @@ pub struct MultiBufferSnapshot {
     parse_count: usize,
     diagnostics_update_count: usize,
     trailing_excerpt_update_count: usize,
+    git_diff_update_count: usize,
     edit_count: usize,
     is_dirty: bool,
     has_conflict: bool,
@@ -202,6 +205,7 @@ impl MultiBuffer {
                     last_selections_update_count: buffer_state.last_selections_update_count,
                     last_diagnostics_update_count: buffer_state.last_diagnostics_update_count,
                     last_file_update_count: buffer_state.last_file_update_count,
+                    last_git_diff_update_count: buffer_state.last_git_diff_update_count,
                     excerpts: buffer_state.excerpts.clone(),
                     _subscriptions: [
                         new_cx.observe(&buffer_state.buffer, |_, _, cx| cx.notify()),
@@ -308,6 +312,17 @@ impl MultiBuffer {
         self.read(cx).symbols_containing(offset, theme)
     }
 
+    pub fn git_diff_recalc(&mut self, cx: &mut ModelContext<Self>) {
+        let buffers = self.buffers.borrow();
+        for buffer_state in buffers.values() {
+            if buffer_state.buffer.read(cx).needs_git_diff_recalc() {
+                buffer_state
+                    .buffer
+                    .update(cx, |buffer, cx| buffer.git_diff_recalc(cx))
+            }
+        }
+    }
+
     pub fn edit<I, S, T>(
         &mut self,
         edits: I,
@@ -827,6 +842,7 @@ impl MultiBuffer {
             last_selections_update_count: buffer_snapshot.selections_update_count(),
             last_diagnostics_update_count: buffer_snapshot.diagnostics_update_count(),
             last_file_update_count: buffer_snapshot.file_update_count(),
+            last_git_diff_update_count: buffer_snapshot.git_diff_update_count(),
             excerpts: Default::default(),
             _subscriptions: [
                 cx.observe(&buffer, |_, _, cx| cx.notify()),
@@ -1249,6 +1265,7 @@ impl MultiBuffer {
         let mut excerpts_to_edit = Vec::new();
         let mut reparsed = false;
         let mut diagnostics_updated = false;
+        let mut git_diff_updated = false;
         let mut is_dirty = false;
         let mut has_conflict = false;
         let mut edited = false;
@@ -1260,6 +1277,7 @@ impl MultiBuffer {
             let selections_update_count = buffer.selections_update_count();
             let diagnostics_update_count = buffer.diagnostics_update_count();
             let file_update_count = buffer.file_update_count();
+            let git_diff_update_count = buffer.git_diff_update_count();
 
             let buffer_edited = version.changed_since(&buffer_state.last_version);
             let buffer_reparsed = parse_count > buffer_state.last_parse_count;
@@ -1268,17 +1286,21 @@ impl MultiBuffer {
             let buffer_diagnostics_updated =
                 diagnostics_update_count > buffer_state.last_diagnostics_update_count;
             let buffer_file_updated = file_update_count > buffer_state.last_file_update_count;
+            let buffer_git_diff_updated =
+                git_diff_update_count > buffer_state.last_git_diff_update_count;
             if buffer_edited
                 || buffer_reparsed
                 || buffer_selections_updated
                 || buffer_diagnostics_updated
                 || buffer_file_updated
+                || buffer_git_diff_updated
             {
                 buffer_state.last_version = version;
                 buffer_state.last_parse_count = parse_count;
                 buffer_state.last_selections_update_count = selections_update_count;
                 buffer_state.last_diagnostics_update_count = diagnostics_update_count;
                 buffer_state.last_file_update_count = file_update_count;
+                buffer_state.last_git_diff_update_count = git_diff_update_count;
                 excerpts_to_edit.extend(
                     buffer_state
                         .excerpts
@@ -1290,6 +1312,7 @@ impl MultiBuffer {
             edited |= buffer_edited;
             reparsed |= buffer_reparsed;
             diagnostics_updated |= buffer_diagnostics_updated;
+            git_diff_updated |= buffer_git_diff_updated;
             is_dirty |= buffer.is_dirty();
             has_conflict |= buffer.has_conflict();
         }
@@ -1302,6 +1325,9 @@ impl MultiBuffer {
         if diagnostics_updated {
             snapshot.diagnostics_update_count += 1;
         }
+        if git_diff_updated {
+            snapshot.git_diff_update_count += 1;
+        }
         snapshot.is_dirty = is_dirty;
         snapshot.has_conflict = has_conflict;
 
@@ -2479,6 +2505,10 @@ impl MultiBufferSnapshot {
         self.diagnostics_update_count
     }
 
+    pub fn git_diff_update_count(&self) -> usize {
+        self.git_diff_update_count
+    }
+
     pub fn trailing_excerpt_update_count(&self) -> usize {
         self.trailing_excerpt_update_count
     }
@@ -2529,6 +2559,15 @@ impl MultiBufferSnapshot {
             })
     }
 
+    pub fn git_diff_hunks_in_range<'a>(
+        &'a self,
+        row_range: Range<u32>,
+    ) -> impl 'a + Iterator<Item = DiffHunk<u32>> {
+        self.as_singleton()
+            .into_iter()
+            .flat_map(move |(_, _, buffer)| buffer.git_diff_hunks_in_range(row_range.clone()))
+    }
+
     pub fn range_for_syntax_ancestor<T: ToOffset>(&self, range: Range<T>) -> Option<Range<usize>> {
         let range = range.start.to_offset(self)..range.end.to_offset(self);
 

crates/git/Cargo.toml 🔗

@@ -0,0 +1,28 @@
+[package]
+name = "git"
+version = "0.1.0"
+edition = "2021"
+
+[lib]
+path = "src/git.rs"
+
+[dependencies]
+anyhow = "1.0.38"
+clock = { path = "../clock" }
+git2 = { version = "0.15", default-features = false }
+lazy_static = "1.4.0"
+sum_tree = { path = "../sum_tree" }
+text = { path = "../text" }
+collections = { path = "../collections" }
+util = { path = "../util" }
+log = { version = "0.4.16", features = ["kv_unstable_serde"] }
+smol = "1.2"
+parking_lot = "0.11.1"
+async-trait = "0.1"
+futures = "0.3"
+
+[dev-dependencies]
+unindent = "0.1.7"
+
+[features]
+test-support = []

crates/git/src/diff.rs 🔗

@@ -0,0 +1,354 @@
+use std::ops::Range;
+
+use sum_tree::SumTree;
+use text::{Anchor, BufferSnapshot, OffsetRangeExt, Point};
+
+pub use git2 as libgit;
+use libgit::{DiffLineType as GitDiffLineType, DiffOptions as GitOptions, Patch as GitPatch};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum DiffHunkStatus {
+    Added,
+    Modified,
+    Removed,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct DiffHunk<T> {
+    pub buffer_range: Range<T>,
+    pub head_byte_range: Range<usize>,
+}
+
+impl DiffHunk<u32> {
+    pub fn status(&self) -> DiffHunkStatus {
+        if self.head_byte_range.is_empty() {
+            DiffHunkStatus::Added
+        } else if self.buffer_range.is_empty() {
+            DiffHunkStatus::Removed
+        } else {
+            DiffHunkStatus::Modified
+        }
+    }
+}
+
+impl sum_tree::Item for DiffHunk<Anchor> {
+    type Summary = DiffHunkSummary;
+
+    fn summary(&self) -> Self::Summary {
+        DiffHunkSummary {
+            buffer_range: self.buffer_range.clone(),
+        }
+    }
+}
+
+#[derive(Debug, Default, Clone)]
+pub struct DiffHunkSummary {
+    buffer_range: Range<Anchor>,
+}
+
+impl sum_tree::Summary for DiffHunkSummary {
+    type Context = text::BufferSnapshot;
+
+    fn add_summary(&mut self, other: &Self, buffer: &Self::Context) {
+        self.buffer_range.start = self
+            .buffer_range
+            .start
+            .min(&other.buffer_range.start, buffer);
+        self.buffer_range.end = self.buffer_range.end.max(&other.buffer_range.end, buffer);
+    }
+}
+
+#[derive(Clone)]
+pub struct BufferDiff {
+    last_buffer_version: Option<clock::Global>,
+    tree: SumTree<DiffHunk<Anchor>>,
+}
+
+impl BufferDiff {
+    pub fn new() -> BufferDiff {
+        BufferDiff {
+            last_buffer_version: None,
+            tree: SumTree::new(),
+        }
+    }
+
+    pub fn hunks_in_range<'a>(
+        &'a self,
+        query_row_range: Range<u32>,
+        buffer: &'a BufferSnapshot,
+    ) -> impl 'a + Iterator<Item = DiffHunk<u32>> {
+        let start = buffer.anchor_before(Point::new(query_row_range.start, 0));
+        let end = buffer.anchor_after(Point::new(query_row_range.end, 0));
+
+        let mut cursor = self.tree.filter::<_, DiffHunkSummary>(move |summary| {
+            let before_start = summary.buffer_range.end.cmp(&start, buffer).is_lt();
+            let after_end = summary.buffer_range.start.cmp(&end, buffer).is_gt();
+            !before_start && !after_end
+        });
+
+        std::iter::from_fn(move || {
+            cursor.next(buffer);
+            let hunk = cursor.item()?;
+
+            let range = hunk.buffer_range.to_point(buffer);
+            let end_row = if range.end.column > 0 {
+                range.end.row + 1
+            } else {
+                range.end.row
+            };
+
+            Some(DiffHunk {
+                buffer_range: range.start.row..end_row,
+                head_byte_range: hunk.head_byte_range.clone(),
+            })
+        })
+    }
+
+    pub fn needs_update(&self, buffer: &text::BufferSnapshot) -> bool {
+        match &self.last_buffer_version {
+            Some(last) => buffer.version().changed_since(last),
+            None => true,
+        }
+    }
+
+    pub async fn update(&mut self, diff_base: &str, buffer: &text::BufferSnapshot) {
+        let mut tree = SumTree::new();
+
+        let buffer_text = buffer.as_rope().to_string();
+        let patch = Self::diff(&diff_base, &buffer_text);
+
+        if let Some(patch) = patch {
+            let mut divergence = 0;
+            for hunk_index in 0..patch.num_hunks() {
+                let hunk = Self::process_patch_hunk(&patch, hunk_index, buffer, &mut divergence);
+                tree.push(hunk, buffer);
+            }
+        }
+
+        self.tree = tree;
+        self.last_buffer_version = Some(buffer.version().clone());
+    }
+
+    #[cfg(test)]
+    fn hunks<'a>(&'a self, text: &'a BufferSnapshot) -> impl 'a + Iterator<Item = DiffHunk<u32>> {
+        self.hunks_in_range(0..u32::MAX, text)
+    }
+
+    fn diff<'a>(head: &'a str, current: &'a str) -> Option<GitPatch<'a>> {
+        let mut options = GitOptions::default();
+        options.context_lines(0);
+
+        let patch = GitPatch::from_buffers(
+            head.as_bytes(),
+            None,
+            current.as_bytes(),
+            None,
+            Some(&mut options),
+        );
+
+        match patch {
+            Ok(patch) => Some(patch),
+
+            Err(err) => {
+                log::error!("`GitPatch::from_buffers` failed: {}", err);
+                None
+            }
+        }
+    }
+
+    fn process_patch_hunk<'a>(
+        patch: &GitPatch<'a>,
+        hunk_index: usize,
+        buffer: &text::BufferSnapshot,
+        buffer_row_divergence: &mut i64,
+    ) -> DiffHunk<Anchor> {
+        let line_item_count = patch.num_lines_in_hunk(hunk_index).unwrap();
+        assert!(line_item_count > 0);
+
+        let mut first_deletion_buffer_row: Option<u32> = None;
+        let mut buffer_row_range: Option<Range<u32>> = None;
+        let mut head_byte_range: Option<Range<usize>> = None;
+
+        for line_index in 0..line_item_count {
+            let line = patch.line_in_hunk(hunk_index, line_index).unwrap();
+            let kind = line.origin_value();
+            let content_offset = line.content_offset() as isize;
+            let content_len = line.content().len() as isize;
+
+            if kind == GitDiffLineType::Addition {
+                *buffer_row_divergence += 1;
+                let row = line.new_lineno().unwrap().saturating_sub(1);
+
+                match &mut buffer_row_range {
+                    Some(buffer_row_range) => buffer_row_range.end = row + 1,
+                    None => buffer_row_range = Some(row..row + 1),
+                }
+            }
+
+            if kind == GitDiffLineType::Deletion {
+                *buffer_row_divergence -= 1;
+                let end = content_offset + content_len;
+
+                match &mut head_byte_range {
+                    Some(head_byte_range) => head_byte_range.end = end as usize,
+                    None => head_byte_range = Some(content_offset as usize..end as usize),
+                }
+
+                if first_deletion_buffer_row.is_none() {
+                    let old_row = line.old_lineno().unwrap().saturating_sub(1);
+                    let row = old_row as i64 + *buffer_row_divergence;
+                    first_deletion_buffer_row = Some(row as u32);
+                }
+            }
+        }
+
+        //unwrap_or deletion without addition
+        let buffer_row_range = buffer_row_range.unwrap_or_else(|| {
+            //we cannot have an addition-less hunk without deletion(s) or else there would be no hunk
+            let row = first_deletion_buffer_row.unwrap();
+            row..row
+        });
+
+        //unwrap_or addition without deletion
+        let head_byte_range = head_byte_range.unwrap_or(0..0);
+
+        let start = Point::new(buffer_row_range.start, 0);
+        let end = Point::new(buffer_row_range.end, 0);
+        let buffer_range = buffer.anchor_before(start)..buffer.anchor_before(end);
+        DiffHunk {
+            buffer_range,
+            head_byte_range,
+        }
+    }
+}
+
+/// Range (crossing new lines), old, new
+#[cfg(any(test, feature = "test-support"))]
+#[track_caller]
+pub fn assert_hunks<Iter>(
+    diff_hunks: Iter,
+    buffer: &BufferSnapshot,
+    diff_base: &str,
+    expected_hunks: &[(Range<u32>, &str, &str)],
+) where
+    Iter: Iterator<Item = DiffHunk<u32>>,
+{
+    let actual_hunks = diff_hunks
+        .map(|hunk| {
+            (
+                hunk.buffer_range.clone(),
+                &diff_base[hunk.head_byte_range],
+                buffer
+                    .text_for_range(
+                        Point::new(hunk.buffer_range.start, 0)
+                            ..Point::new(hunk.buffer_range.end, 0),
+                    )
+                    .collect::<String>(),
+            )
+        })
+        .collect::<Vec<_>>();
+
+    let expected_hunks: Vec<_> = expected_hunks
+        .iter()
+        .map(|(r, s, h)| (r.clone(), *s, h.to_string()))
+        .collect();
+
+    assert_eq!(actual_hunks, expected_hunks);
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use text::Buffer;
+    use unindent::Unindent as _;
+
+    #[test]
+    fn test_buffer_diff_simple() {
+        let diff_base = "
+            one
+            two
+            three
+        "
+        .unindent();
+
+        let buffer_text = "
+            one
+            HELLO
+            three
+        "
+        .unindent();
+
+        let mut buffer = Buffer::new(0, 0, buffer_text);
+        let mut diff = BufferDiff::new();
+        smol::block_on(diff.update(&diff_base, &buffer));
+        assert_hunks(
+            diff.hunks(&buffer),
+            &buffer,
+            &diff_base,
+            &[(1..2, "two\n", "HELLO\n")],
+        );
+
+        buffer.edit([(0..0, "point five\n")]);
+        smol::block_on(diff.update(&diff_base, &buffer));
+        assert_hunks(
+            diff.hunks(&buffer),
+            &buffer,
+            &diff_base,
+            &[(0..1, "", "point five\n"), (2..3, "two\n", "HELLO\n")],
+        );
+    }
+
+    #[test]
+    fn test_buffer_diff_range() {
+        let diff_base = "
+            one
+            two
+            three
+            four
+            five
+            six
+            seven
+            eight
+            nine
+            ten
+        "
+        .unindent();
+
+        let buffer_text = "
+            A
+            one
+            B
+            two
+            C
+            three
+            HELLO
+            four
+            five
+            SIXTEEN
+            seven
+            eight
+            WORLD
+            nine
+
+            ten
+
+        "
+        .unindent();
+
+        let buffer = Buffer::new(0, 0, buffer_text);
+        let mut diff = BufferDiff::new();
+        smol::block_on(diff.update(&diff_base, &buffer));
+        assert_eq!(diff.hunks(&buffer).count(), 8);
+
+        assert_hunks(
+            diff.hunks_in_range(7..12, &buffer),
+            &buffer,
+            &diff_base,
+            &[
+                (6..7, "", "HELLO\n"),
+                (9..10, "six\n", "SIXTEEN\n"),
+                (12..13, "", "WORLD\n"),
+            ],
+        );
+    }
+}

crates/git/src/git.rs 🔗

@@ -0,0 +1,12 @@
+use std::ffi::OsStr;
+
+pub use git2 as libgit;
+pub use lazy_static::lazy_static;
+
+pub mod diff;
+pub mod repository;
+
+lazy_static! {
+    pub static ref DOT_GIT: &'static OsStr = OsStr::new(".git");
+    pub static ref GITIGNORE: &'static OsStr = OsStr::new(".gitignore");
+}

crates/git/src/repository.rs 🔗

@@ -0,0 +1,61 @@
+use anyhow::Result;
+use collections::HashMap;
+use parking_lot::Mutex;
+use std::{
+    path::{Path, PathBuf},
+    sync::Arc,
+};
+
+pub use git2::Repository as LibGitRepository;
+
+#[async_trait::async_trait]
+pub trait GitRepository: Send {
+    fn load_index(&self, relative_file_path: &Path) -> Option<String>;
+}
+
+#[async_trait::async_trait]
+impl GitRepository for LibGitRepository {
+    fn load_index(&self, relative_file_path: &Path) -> Option<String> {
+        fn logic(repo: &LibGitRepository, relative_file_path: &Path) -> Result<Option<String>> {
+            const STAGE_NORMAL: i32 = 0;
+            let index = repo.index()?;
+            let oid = match index.get_path(relative_file_path, STAGE_NORMAL) {
+                Some(entry) => entry.id,
+                None => return Ok(None),
+            };
+
+            let content = repo.find_blob(oid)?.content().to_owned();
+            Ok(Some(String::from_utf8(content)?))
+        }
+
+        match logic(&self, relative_file_path) {
+            Ok(value) => return value,
+            Err(err) => log::error!("Error loading head text: {:?}", err),
+        }
+        None
+    }
+}
+
+#[derive(Debug, Clone, Default)]
+pub struct FakeGitRepository {
+    state: Arc<Mutex<FakeGitRepositoryState>>,
+}
+
+#[derive(Debug, Clone, Default)]
+pub struct FakeGitRepositoryState {
+    pub index_contents: HashMap<PathBuf, String>,
+}
+
+impl FakeGitRepository {
+    pub fn open(state: Arc<Mutex<FakeGitRepositoryState>>) -> Arc<Mutex<dyn GitRepository>> {
+        Arc::new(Mutex::new(FakeGitRepository { state }))
+    }
+}
+
+#[async_trait::async_trait]
+impl GitRepository for FakeGitRepository {
+    fn load_index(&self, path: &Path) -> Option<String> {
+        let state = self.state.lock();
+        state.index_contents.get(path).cloned()
+    }
+}

crates/language/Cargo.toml 🔗

@@ -25,6 +25,7 @@ client = { path = "../client" }
 clock = { path = "../clock" }
 collections = { path = "../collections" }
 fuzzy = { path = "../fuzzy" }
+git = { path = "../git" }
 gpui = { path = "../gpui" }
 lsp = { path = "../lsp" }
 rpc = { path = "../rpc" }

crates/language/src/buffer.rs 🔗

@@ -45,8 +45,16 @@ pub use {tree_sitter_rust, tree_sitter_typescript};
 
 pub use lsp::DiagnosticSeverity;
 
+struct GitDiffStatus {
+    diff: git::diff::BufferDiff,
+    update_in_progress: bool,
+    update_requested: bool,
+}
+
 pub struct Buffer {
     text: TextBuffer,
+    diff_base: Option<String>,
+    git_diff_status: GitDiffStatus,
     file: Option<Arc<dyn File>>,
     saved_version: clock::Global,
     saved_version_fingerprint: String,
@@ -66,6 +74,7 @@ pub struct Buffer {
     diagnostics_update_count: usize,
     diagnostics_timestamp: clock::Lamport,
     file_update_count: usize,
+    git_diff_update_count: usize,
     completion_triggers: Vec<String>,
     completion_triggers_timestamp: clock::Lamport,
     deferred_ops: OperationQueue<Operation>,
@@ -73,11 +82,13 @@ pub struct Buffer {
 
 pub struct BufferSnapshot {
     text: text::BufferSnapshot,
+    pub git_diff: git::diff::BufferDiff,
     pub(crate) syntax: SyntaxSnapshot,
     file: Option<Arc<dyn File>>,
     diagnostics: DiagnosticSet,
     diagnostics_update_count: usize,
     file_update_count: usize,
+    git_diff_update_count: usize,
     remote_selections: TreeMap<ReplicaId, SelectionSet>,
     selections_update_count: usize,
     language: Option<Arc<Language>>,
@@ -328,17 +339,20 @@ impl Buffer {
         Self::build(
             TextBuffer::new(replica_id, cx.model_id() as u64, base_text.into()),
             None,
+            None,
         )
     }
 
     pub fn from_file<T: Into<String>>(
         replica_id: ReplicaId,
         base_text: T,
+        diff_base: Option<T>,
         file: Arc<dyn File>,
         cx: &mut ModelContext<Self>,
     ) -> Self {
         Self::build(
             TextBuffer::new(replica_id, cx.model_id() as u64, base_text.into()),
+            diff_base.map(|h| h.into().into_boxed_str().into()),
             Some(file),
         )
     }
@@ -349,7 +363,11 @@ impl Buffer {
         file: Option<Arc<dyn File>>,
     ) -> Result<Self> {
         let buffer = TextBuffer::new(replica_id, message.id, message.base_text);
-        let mut this = Self::build(buffer, file);
+        let mut this = Self::build(
+            buffer,
+            message.diff_base.map(|text| text.into_boxed_str().into()),
+            file,
+        );
         this.text.set_line_ending(proto::deserialize_line_ending(
             proto::LineEnding::from_i32(message.line_ending)
                 .ok_or_else(|| anyhow!("missing line_ending"))?,
@@ -362,6 +380,7 @@ impl Buffer {
             id: self.remote_id(),
             file: self.file.as_ref().map(|f| f.to_proto()),
             base_text: self.base_text().to_string(),
+            diff_base: self.diff_base.as_ref().map(|h| h.to_string()),
             line_ending: proto::serialize_line_ending(self.line_ending()) as i32,
         }
     }
@@ -404,7 +423,7 @@ impl Buffer {
         self
     }
 
-    fn build(buffer: TextBuffer, file: Option<Arc<dyn File>>) -> Self {
+    fn build(buffer: TextBuffer, diff_base: Option<String>, file: Option<Arc<dyn File>>) -> Self {
         let saved_mtime = if let Some(file) = file.as_ref() {
             file.mtime()
         } else {
@@ -418,6 +437,12 @@ impl Buffer {
             transaction_depth: 0,
             was_dirty_before_starting_transaction: None,
             text: buffer,
+            diff_base,
+            git_diff_status: GitDiffStatus {
+                diff: git::diff::BufferDiff::new(),
+                update_in_progress: false,
+                update_requested: false,
+            },
             file,
             syntax_map: Mutex::new(SyntaxMap::new()),
             parsing_in_background: false,
@@ -432,6 +457,7 @@ impl Buffer {
             diagnostics_update_count: 0,
             diagnostics_timestamp: Default::default(),
             file_update_count: 0,
+            git_diff_update_count: 0,
             completion_triggers: Default::default(),
             completion_triggers_timestamp: Default::default(),
             deferred_ops: OperationQueue::new(),
@@ -447,11 +473,13 @@ impl Buffer {
         BufferSnapshot {
             text,
             syntax,
+            git_diff: self.git_diff_status.diff.clone(),
             file: self.file.clone(),
             remote_selections: self.remote_selections.clone(),
             diagnostics: self.diagnostics.clone(),
             diagnostics_update_count: self.diagnostics_update_count,
             file_update_count: self.file_update_count,
+            git_diff_update_count: self.git_diff_update_count,
             language: self.language.clone(),
             parse_count: self.parse_count,
             selections_update_count: self.selections_update_count,
@@ -584,6 +612,7 @@ impl Buffer {
                 cx,
             );
         }
+        self.git_diff_recalc(cx);
         cx.emit(Event::Reloaded);
         cx.notify();
     }
@@ -633,6 +662,55 @@ impl Buffer {
         task
     }
 
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn diff_base(&self) -> Option<&str> {
+        self.diff_base.as_deref()
+    }
+
+    pub fn update_diff_base(&mut self, diff_base: Option<String>, cx: &mut ModelContext<Self>) {
+        self.diff_base = diff_base;
+        self.git_diff_recalc(cx);
+    }
+
+    pub fn needs_git_diff_recalc(&self) -> bool {
+        self.git_diff_status.diff.needs_update(self)
+    }
+
+    pub fn git_diff_recalc(&mut self, cx: &mut ModelContext<Self>) {
+        if self.git_diff_status.update_in_progress {
+            self.git_diff_status.update_requested = true;
+            return;
+        }
+
+        if let Some(diff_base) = &self.diff_base {
+            let snapshot = self.snapshot();
+            let diff_base = diff_base.clone();
+
+            let mut diff = self.git_diff_status.diff.clone();
+            let diff = cx.background().spawn(async move {
+                diff.update(&diff_base, &snapshot).await;
+                diff
+            });
+
+            cx.spawn_weak(|this, mut cx| async move {
+                let buffer_diff = diff.await;
+                if let Some(this) = this.upgrade(&cx) {
+                    this.update(&mut cx, |this, cx| {
+                        this.git_diff_status.diff = buffer_diff;
+                        this.git_diff_update_count += 1;
+                        cx.notify();
+
+                        this.git_diff_status.update_in_progress = false;
+                        if this.git_diff_status.update_requested {
+                            this.git_diff_recalc(cx);
+                        }
+                    })
+                }
+            })
+            .detach()
+        }
+    }
+
     pub fn close(&mut self, cx: &mut ModelContext<Self>) {
         cx.emit(Event::Closed);
     }
@@ -657,6 +735,10 @@ impl Buffer {
         self.file_update_count
     }
 
+    pub fn git_diff_update_count(&self) -> usize {
+        self.git_diff_update_count
+    }
+
     #[cfg(any(test, feature = "test-support"))]
     pub fn is_parsing(&self) -> bool {
         self.parsing_in_background
@@ -2139,6 +2221,13 @@ impl BufferSnapshot {
             })
     }
 
+    pub fn git_diff_hunks_in_range<'a>(
+        &'a self,
+        query_row_range: Range<u32>,
+    ) -> impl 'a + Iterator<Item = git::diff::DiffHunk<u32>> {
+        self.git_diff.hunks_in_range(query_row_range, self)
+    }
+
     pub fn diagnostics_in_range<'a, T, O>(
         &'a self,
         search_range: Range<T>,
@@ -2186,6 +2275,10 @@ impl BufferSnapshot {
     pub fn file_update_count(&self) -> usize {
         self.file_update_count
     }
+
+    pub fn git_diff_update_count(&self) -> usize {
+        self.git_diff_update_count
+    }
 }
 
 pub fn indent_size_for_line(text: &text::BufferSnapshot, row: u32) -> IndentSize {
@@ -2212,6 +2305,7 @@ impl Clone for BufferSnapshot {
     fn clone(&self) -> Self {
         Self {
             text: self.text.clone(),
+            git_diff: self.git_diff.clone(),
             syntax: self.syntax.clone(),
             file: self.file.clone(),
             remote_selections: self.remote_selections.clone(),
@@ -2219,6 +2313,7 @@ impl Clone for BufferSnapshot {
             selections_update_count: self.selections_update_count,
             diagnostics_update_count: self.diagnostics_update_count,
             file_update_count: self.file_update_count,
+            git_diff_update_count: self.git_diff_update_count,
             language: self.language.clone(),
             parse_count: self.parse_count,
         }

crates/project/Cargo.toml 🔗

@@ -24,6 +24,7 @@ collections = { path = "../collections" }
 db = { path = "../db" }
 fsevent = { path = "../fsevent" }
 fuzzy = { path = "../fuzzy" }
+git = { path = "../git" }
 gpui = { path = "../gpui" }
 language = { path = "../language" }
 lsp = { path = "../lsp" }

crates/project/src/fs.rs 🔗

@@ -1,8 +1,11 @@
 use anyhow::{anyhow, Result};
 use fsevent::EventStream;
 use futures::{future::BoxFuture, Stream, StreamExt};
+use git::repository::{GitRepository, LibGitRepository};
 use language::LineEnding;
+use parking_lot::Mutex as SyncMutex;
 use smol::io::{AsyncReadExt, AsyncWriteExt};
+use std::sync::Arc;
 use std::{
     io,
     os::unix::fs::MetadataExt,
@@ -11,13 +14,16 @@ use std::{
     time::{Duration, SystemTime},
 };
 use text::Rope;
+use util::ResultExt;
 
 #[cfg(any(test, feature = "test-support"))]
 use collections::{btree_map, BTreeMap};
 #[cfg(any(test, feature = "test-support"))]
 use futures::lock::Mutex;
 #[cfg(any(test, feature = "test-support"))]
-use std::sync::{Arc, Weak};
+use git::repository::FakeGitRepositoryState;
+#[cfg(any(test, feature = "test-support"))]
+use std::sync::Weak;
 
 #[async_trait::async_trait]
 pub trait Fs: Send + Sync {
@@ -42,6 +48,7 @@ pub trait Fs: Send + Sync {
         path: &Path,
         latency: Duration,
     ) -> Pin<Box<dyn Send + Stream<Item = Vec<fsevent::Event>>>>;
+    fn open_repo(&self, abs_dot_git: &Path) -> Option<Arc<SyncMutex<dyn GitRepository>>>;
     fn is_fake(&self) -> bool;
     #[cfg(any(test, feature = "test-support"))]
     fn as_fake(&self) -> &FakeFs;
@@ -235,6 +242,14 @@ impl Fs for RealFs {
         })))
     }
 
+    fn open_repo(&self, dotgit_path: &Path) -> Option<Arc<SyncMutex<dyn GitRepository>>> {
+        LibGitRepository::open(&dotgit_path)
+            .log_err()
+            .and_then::<Arc<SyncMutex<dyn GitRepository>>, _>(|libgit_repository| {
+                Some(Arc::new(SyncMutex::new(libgit_repository)))
+            })
+    }
+
     fn is_fake(&self) -> bool {
         false
     }
@@ -270,6 +285,7 @@ enum FakeFsEntry {
         inode: u64,
         mtime: SystemTime,
         entries: BTreeMap<String, Arc<Mutex<FakeFsEntry>>>,
+        git_repo_state: Option<Arc<SyncMutex<git::repository::FakeGitRepositoryState>>>,
     },
     Symlink {
         target: PathBuf,
@@ -384,6 +400,7 @@ impl FakeFs {
                     inode: 0,
                     mtime: SystemTime::now(),
                     entries: Default::default(),
+                    git_repo_state: None,
                 })),
                 next_inode: 1,
                 event_txs: Default::default(),
@@ -473,6 +490,29 @@ impl FakeFs {
         .boxed()
     }
 
+    pub async fn set_index_for_repo(&self, dot_git: &Path, head_state: &[(&Path, String)]) {
+        let content_path = dot_git.parent().unwrap();
+        let mut state = self.state.lock().await;
+        let entry = state.read_path(dot_git).await.unwrap();
+        let mut entry = entry.lock().await;
+
+        if let FakeFsEntry::Dir { git_repo_state, .. } = &mut *entry {
+            let repo_state = git_repo_state.get_or_insert_with(Default::default);
+            let mut repo_state = repo_state.lock();
+
+            repo_state.index_contents.clear();
+            repo_state.index_contents.extend(
+                head_state
+                    .iter()
+                    .map(|(path, content)| (content_path.join(path), content.clone())),
+            );
+
+            state.emit_event([dot_git]);
+        } else {
+            panic!("not a directory");
+        }
+    }
+
     pub async fn files(&self) -> Vec<PathBuf> {
         let mut result = Vec::new();
         let mut queue = collections::VecDeque::new();
@@ -562,6 +602,7 @@ impl Fs for FakeFs {
                             inode,
                             mtime: SystemTime::now(),
                             entries: Default::default(),
+                            git_repo_state: None,
                         }))
                     });
                     Ok(())
@@ -846,6 +887,24 @@ impl Fs for FakeFs {
         }))
     }
 
+    fn open_repo(&self, abs_dot_git: &Path) -> Option<Arc<SyncMutex<dyn GitRepository>>> {
+        smol::block_on(async move {
+            let state = self.state.lock().await;
+            let entry = state.read_path(abs_dot_git).await.unwrap();
+            let mut entry = entry.lock().await;
+            if let FakeFsEntry::Dir { git_repo_state, .. } = &mut *entry {
+                let state = git_repo_state
+                    .get_or_insert_with(|| {
+                        Arc::new(SyncMutex::new(FakeGitRepositoryState::default()))
+                    })
+                    .clone();
+                Some(git::repository::FakeGitRepository::open(state))
+            } else {
+                None
+            }
+        })
+    }
+
     fn is_fake(&self) -> bool {
         true
     }

crates/project/src/project.rs 🔗

@@ -8,10 +8,14 @@ pub mod worktree;
 mod project_tests;
 
 use anyhow::{anyhow, Context, Result};
-use client::{proto, Client, PeerId, TypedEnvelope, User, UserStore};
+use client::{
+    proto::{self},
+    Client, PeerId, TypedEnvelope, User, UserStore,
+};
 use clock::ReplicaId;
 use collections::{hash_map, BTreeMap, HashMap, HashSet};
 use futures::{future::Shared, AsyncWriteExt, Future, FutureExt, StreamExt, TryFutureExt};
+
 use gpui::{
     AnyModelHandle, AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle,
     MutableAppContext, Task, UpgradeModelHandle, WeakModelHandle,
@@ -420,6 +424,7 @@ impl Project {
         client.add_model_request_handler(Self::handle_open_buffer_by_id);
         client.add_model_request_handler(Self::handle_open_buffer_by_path);
         client.add_model_request_handler(Self::handle_save_buffer);
+        client.add_model_message_handler(Self::handle_update_diff_base);
     }
 
     pub fn local(
@@ -4533,8 +4538,11 @@ impl Project {
     fn add_worktree(&mut self, worktree: &ModelHandle<Worktree>, cx: &mut ModelContext<Self>) {
         cx.observe(worktree, |_, _, cx| cx.notify()).detach();
         if worktree.read(cx).is_local() {
-            cx.subscribe(worktree, |this, worktree, _, cx| {
-                this.update_local_worktree_buffers(worktree, cx);
+            cx.subscribe(worktree, |this, worktree, event, cx| match event {
+                worktree::Event::UpdatedEntries => this.update_local_worktree_buffers(worktree, cx),
+                worktree::Event::UpdatedGitRepositories(updated_repos) => {
+                    this.update_local_worktree_buffers_git_repos(worktree, updated_repos, cx)
+                }
             })
             .detach();
         }
@@ -4642,6 +4650,58 @@ impl Project {
         }
     }
 
+    fn update_local_worktree_buffers_git_repos(
+        &mut self,
+        worktree: ModelHandle<Worktree>,
+        repos: &[GitRepositoryEntry],
+        cx: &mut ModelContext<Self>,
+    ) {
+        for (_, buffer) in &self.opened_buffers {
+            if let Some(buffer) = buffer.upgrade(cx) {
+                let file = match File::from_dyn(buffer.read(cx).file()) {
+                    Some(file) => file,
+                    None => continue,
+                };
+                if file.worktree != worktree {
+                    continue;
+                }
+
+                let path = file.path().clone();
+
+                let repo = match repos.iter().find(|repo| repo.manages(&path)) {
+                    Some(repo) => repo.clone(),
+                    None => return,
+                };
+
+                let shared_remote_id = self.shared_remote_id();
+                let client = self.client.clone();
+
+                cx.spawn(|_, mut cx| async move {
+                    let diff_base = cx
+                        .background()
+                        .spawn(async move { repo.repo.lock().load_index(&path) })
+                        .await;
+
+                    let buffer_id = buffer.update(&mut cx, |buffer, cx| {
+                        buffer.update_diff_base(diff_base.clone(), cx);
+                        buffer.remote_id()
+                    });
+
+                    if let Some(project_id) = shared_remote_id {
+                        client
+                            .send(proto::UpdateDiffBase {
+                                project_id,
+                                buffer_id: buffer_id as u64,
+                                diff_base,
+                            })
+                            .log_err();
+                    }
+                })
+                .detach();
+            }
+        }
+    }
+
     pub fn set_active_path(&mut self, entry: Option<ProjectPath>, cx: &mut ModelContext<Self>) {
         let new_active_entry = entry.and_then(|project_path| {
             let worktree = self.worktree_for_id(project_path.worktree_id, cx)?;
@@ -5214,6 +5274,27 @@ impl Project {
         })
     }
 
+    async fn handle_update_diff_base(
+        this: ModelHandle<Self>,
+        envelope: TypedEnvelope<proto::UpdateDiffBase>,
+        _: Arc<Client>,
+        mut cx: AsyncAppContext,
+    ) -> Result<()> {
+        this.update(&mut cx, |this, cx| {
+            let buffer_id = envelope.payload.buffer_id;
+            let diff_base = envelope.payload.diff_base;
+            let buffer = this
+                .opened_buffers
+                .get_mut(&buffer_id)
+                .and_then(|b| b.upgrade(cx))
+                .ok_or_else(|| anyhow!("No such buffer {}", buffer_id))?;
+
+            buffer.update(cx, |buffer, cx| buffer.update_diff_base(diff_base, cx));
+
+            Ok(())
+        })
+    }
+
     async fn handle_update_buffer_file(
         this: ModelHandle<Self>,
         envelope: TypedEnvelope<proto::UpdateBufferFile>,
@@ -5780,7 +5861,7 @@ impl Project {
         cx: &mut ModelContext<Self>,
     ) -> Task<Result<ModelHandle<Buffer>>> {
         let mut opened_buffer_rx = self.opened_buffer.1.clone();
-        cx.spawn(|this, cx| async move {
+        cx.spawn(|this, mut cx| async move {
             let buffer = loop {
                 let buffer = this.read_with(&cx, |this, cx| {
                     this.opened_buffers
@@ -5798,6 +5879,7 @@ impl Project {
                     .await
                     .ok_or_else(|| anyhow!("project dropped while waiting for buffer"))?;
             };
+            buffer.update(&mut cx, |buffer, cx| buffer.git_diff_recalc(cx));
             Ok(buffer)
         })
     }

crates/project/src/worktree.rs 🔗

@@ -1,10 +1,9 @@
-use crate::{copy_recursive, ProjectEntryId, RemoveOptions};
-
 use super::{
     fs::{self, Fs},
     ignore::IgnoreStack,
     DiagnosticSummary,
 };
+use crate::{copy_recursive, ProjectEntryId, RemoveOptions};
 use ::ignore::gitignore::{Gitignore, GitignoreBuilder};
 use anyhow::{anyhow, Context, Result};
 use client::{proto, Client};
@@ -18,6 +17,8 @@ use futures::{
     Stream, StreamExt,
 };
 use fuzzy::CharBag;
+use git::repository::GitRepository;
+use git::{DOT_GIT, GITIGNORE};
 use gpui::{
     executor, AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle, MutableAppContext,
     Task,
@@ -26,12 +27,12 @@ use language::{
     proto::{deserialize_version, serialize_line_ending, serialize_version},
     Buffer, DiagnosticEntry, LineEnding, PointUtf16, Rope,
 };
-use lazy_static::lazy_static;
 use parking_lot::Mutex;
 use postage::{
     prelude::{Sink as _, Stream as _},
     watch,
 };
+
 use smol::channel::{self, Sender};
 use std::{
     any::Any,
@@ -40,6 +41,7 @@ use std::{
     ffi::{OsStr, OsString},
     fmt,
     future::Future,
+    mem,
     ops::{Deref, DerefMut},
     os::unix::prelude::{OsStrExt, OsStringExt},
     path::{Path, PathBuf},
@@ -50,10 +52,6 @@ use std::{
 use sum_tree::{Bias, Edit, SeekTarget, SumTree, TreeMap, TreeSet};
 use util::{ResultExt, TryFutureExt};
 
-lazy_static! {
-    static ref GITIGNORE: &'static OsStr = OsStr::new(".gitignore");
-}
-
 #[derive(Copy, Clone, PartialEq, Eq, Debug, Hash, PartialOrd, Ord)]
 pub struct WorktreeId(usize);
 
@@ -101,15 +99,51 @@ pub struct Snapshot {
 }
 
 #[derive(Clone)]
+pub struct GitRepositoryEntry {
+    pub(crate) repo: Arc<Mutex<dyn GitRepository>>,
+
+    pub(crate) scan_id: usize,
+    // Path to folder containing the .git file or directory
+    pub(crate) content_path: Arc<Path>,
+    // Path to the actual .git folder.
+    // Note: if .git is a file, this points to the folder indicated by the .git file
+    pub(crate) git_dir_path: Arc<Path>,
+}
+
+impl std::fmt::Debug for GitRepositoryEntry {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("GitRepositoryEntry")
+            .field("content_path", &self.content_path)
+            .field("git_dir_path", &self.git_dir_path)
+            .field("libgit_repository", &"LibGitRepository")
+            .finish()
+    }
+}
+
 pub struct LocalSnapshot {
     abs_path: Arc<Path>,
     ignores_by_parent_abs_path: HashMap<Arc<Path>, (Arc<Gitignore>, usize)>,
+    git_repositories: Vec<GitRepositoryEntry>,
     removed_entry_ids: HashMap<u64, ProjectEntryId>,
     next_entry_id: Arc<AtomicUsize>,
     snapshot: Snapshot,
     extension_counts: HashMap<OsString, usize>,
 }
 
+impl Clone for LocalSnapshot {
+    fn clone(&self) -> Self {
+        Self {
+            abs_path: self.abs_path.clone(),
+            ignores_by_parent_abs_path: self.ignores_by_parent_abs_path.clone(),
+            git_repositories: self.git_repositories.iter().cloned().collect(),
+            removed_entry_ids: self.removed_entry_ids.clone(),
+            next_entry_id: self.next_entry_id.clone(),
+            snapshot: self.snapshot.clone(),
+            extension_counts: self.extension_counts.clone(),
+        }
+    }
+}
+
 impl Deref for LocalSnapshot {
     type Target = Snapshot;
 
@@ -142,6 +176,7 @@ struct ShareState {
 
 pub enum Event {
     UpdatedEntries,
+    UpdatedGitRepositories(Vec<GitRepositoryEntry>),
 }
 
 impl Entity for Worktree {
@@ -372,6 +407,7 @@ impl LocalWorktree {
             let mut snapshot = LocalSnapshot {
                 abs_path,
                 ignores_by_parent_abs_path: Default::default(),
+                git_repositories: Default::default(),
                 removed_entry_ids: Default::default(),
                 next_entry_id,
                 snapshot: Snapshot {
@@ -446,10 +482,14 @@ impl LocalWorktree {
     ) -> Task<Result<ModelHandle<Buffer>>> {
         let path = Arc::from(path);
         cx.spawn(move |this, mut cx| async move {
-            let (file, contents) = this
+            let (file, contents, diff_base) = this
                 .update(&mut cx, |t, cx| t.as_local().unwrap().load(&path, cx))
                 .await?;
-            Ok(cx.add_model(|cx| Buffer::from_file(0, contents, Arc::new(file), cx)))
+            Ok(cx.add_model(|cx| {
+                let mut buffer = Buffer::from_file(0, contents, diff_base, Arc::new(file), cx);
+                buffer.git_diff_recalc(cx);
+                buffer
+            }))
         })
     }
 
@@ -499,17 +539,37 @@ impl LocalWorktree {
 
     fn poll_snapshot(&mut self, force: bool, cx: &mut ModelContext<Worktree>) {
         self.poll_task.take();
+
         match self.scan_state() {
             ScanState::Idle => {
-                self.snapshot = self.background_snapshot.lock().clone();
+                let new_snapshot = self.background_snapshot.lock().clone();
+                let updated_repos = Self::changed_repos(
+                    &self.snapshot.git_repositories,
+                    &new_snapshot.git_repositories,
+                );
+                self.snapshot = new_snapshot;
+
                 if let Some(share) = self.share.as_mut() {
                     *share.snapshots_tx.borrow_mut() = self.snapshot.clone();
                 }
+
                 cx.emit(Event::UpdatedEntries);
+
+                if !updated_repos.is_empty() {
+                    cx.emit(Event::UpdatedGitRepositories(updated_repos));
+                }
             }
+
             ScanState::Initializing => {
                 let is_fake_fs = self.fs.is_fake();
-                self.snapshot = self.background_snapshot.lock().clone();
+
+                let new_snapshot = self.background_snapshot.lock().clone();
+                let updated_repos = Self::changed_repos(
+                    &self.snapshot.git_repositories,
+                    &new_snapshot.git_repositories,
+                );
+                self.snapshot = new_snapshot;
+
                 self.poll_task = Some(cx.spawn_weak(|this, mut cx| async move {
                     if is_fake_fs {
                         #[cfg(any(test, feature = "test-support"))]
@@ -521,17 +581,52 @@ impl LocalWorktree {
                         this.update(&mut cx, |this, cx| this.poll_snapshot(cx));
                     }
                 }));
+
                 cx.emit(Event::UpdatedEntries);
+
+                if !updated_repos.is_empty() {
+                    cx.emit(Event::UpdatedGitRepositories(updated_repos));
+                }
             }
+
             _ => {
                 if force {
                     self.snapshot = self.background_snapshot.lock().clone();
                 }
             }
         }
+
         cx.notify();
     }
 
+    fn changed_repos(
+        old_repos: &[GitRepositoryEntry],
+        new_repos: &[GitRepositoryEntry],
+    ) -> Vec<GitRepositoryEntry> {
+        fn diff<'a>(
+            a: &'a [GitRepositoryEntry],
+            b: &'a [GitRepositoryEntry],
+            updated: &mut HashMap<&'a Path, GitRepositoryEntry>,
+        ) {
+            for a_repo in a {
+                let matched = b.iter().find(|b_repo| {
+                    a_repo.git_dir_path == b_repo.git_dir_path && a_repo.scan_id == b_repo.scan_id
+                });
+
+                if matched.is_none() {
+                    updated.insert(a_repo.git_dir_path.as_ref(), a_repo.clone());
+                }
+            }
+        }
+
+        let mut updated = HashMap::<&Path, GitRepositoryEntry>::default();
+
+        diff(old_repos, new_repos, &mut updated);
+        diff(new_repos, old_repos, &mut updated);
+
+        updated.into_values().collect()
+    }
+
     pub fn scan_complete(&self) -> impl Future<Output = ()> {
         let mut scan_state_rx = self.last_scan_state_rx.clone();
         async move {
@@ -558,13 +653,31 @@ impl LocalWorktree {
         }
     }
 
-    fn load(&self, path: &Path, cx: &mut ModelContext<Worktree>) -> Task<Result<(File, String)>> {
+    fn load(
+        &self,
+        path: &Path,
+        cx: &mut ModelContext<Worktree>,
+    ) -> Task<Result<(File, String, Option<String>)>> {
         let handle = cx.handle();
         let path = Arc::from(path);
         let abs_path = self.absolutize(&path);
         let fs = self.fs.clone();
+        let snapshot = self.snapshot();
+
         cx.spawn(|this, mut cx| async move {
             let text = fs.load(&abs_path).await?;
+
+            let diff_base = if let Some(repo) = snapshot.repo_for(&abs_path) {
+                cx.background()
+                    .spawn({
+                        let path = path.clone();
+                        async move { repo.repo.lock().load_index(&path) }
+                    })
+                    .await
+            } else {
+                None
+            };
+
             // Eagerly populate the snapshot with an updated entry for the loaded file
             let entry = this
                 .update(&mut cx, |this, cx| {
@@ -573,6 +686,7 @@ impl LocalWorktree {
                         .refresh_entry(path, abs_path, None, cx)
                 })
                 .await?;
+
             Ok((
                 File {
                     entry_id: Some(entry.id),
@@ -582,6 +696,7 @@ impl LocalWorktree {
                     is_local: true,
                 },
                 text,
+                diff_base,
             ))
         })
     }
@@ -1248,6 +1363,22 @@ impl LocalSnapshot {
         &self.extension_counts
     }
 
+    // Gives the most specific git repository for a given path
+    pub(crate) fn repo_for(&self, path: &Path) -> Option<GitRepositoryEntry> {
+        self.git_repositories
+            .iter()
+            .rev() //git_repository is ordered lexicographically
+            .find(|repo| repo.manages(path))
+            .cloned()
+    }
+
+    pub(crate) fn in_dot_git(&mut self, path: &Path) -> Option<&mut GitRepositoryEntry> {
+        // Git repositories cannot be nested, so we don't need to reverse the order
+        self.git_repositories
+            .iter_mut()
+            .find(|repo| repo.in_dot_git(path))
+    }
+
     #[cfg(test)]
     pub(crate) fn build_initial_update(&self, project_id: u64) -> proto::UpdateWorktree {
         let root_name = self.root_name.clone();
@@ -1330,7 +1461,7 @@ impl LocalSnapshot {
     }
 
     fn insert_entry(&mut self, mut entry: Entry, fs: &dyn Fs) -> Entry {
-        if !entry.is_dir() && entry.path.file_name() == Some(&GITIGNORE) {
+        if entry.is_file() && entry.path.file_name() == Some(&GITIGNORE) {
             let abs_path = self.abs_path.join(&entry.path);
             match smol::block_on(build_gitignore(&abs_path, fs)) {
                 Ok(ignore) => {
@@ -1384,6 +1515,7 @@ impl LocalSnapshot {
         parent_path: Arc<Path>,
         entries: impl IntoIterator<Item = Entry>,
         ignore: Option<Arc<Gitignore>>,
+        fs: &dyn Fs,
     ) {
         let mut parent_entry = if let Some(parent_entry) =
             self.entries_by_path.get(&PathKey(parent_path.clone()), &())
@@ -1409,6 +1541,27 @@ impl LocalSnapshot {
             unreachable!();
         }
 
+        if parent_path.file_name() == Some(&DOT_GIT) {
+            let abs_path = self.abs_path.join(&parent_path);
+            let content_path: Arc<Path> = parent_path.parent().unwrap().into();
+            if let Err(ix) = self
+                .git_repositories
+                .binary_search_by_key(&&content_path, |repo| &repo.content_path)
+            {
+                if let Some(repo) = fs.open_repo(abs_path.as_path()) {
+                    self.git_repositories.insert(
+                        ix,
+                        GitRepositoryEntry {
+                            repo,
+                            scan_id: 0,
+                            content_path,
+                            git_dir_path: parent_path,
+                        },
+                    );
+                }
+            }
+        }
+
         let mut entries_by_path_edits = vec![Edit::Insert(parent_entry)];
         let mut entries_by_id_edits = Vec::new();
 
@@ -1493,6 +1646,14 @@ impl LocalSnapshot {
             {
                 *scan_id = self.snapshot.scan_id;
             }
+        } else if path.file_name() == Some(&DOT_GIT) {
+            let parent_path = path.parent().unwrap();
+            if let Ok(ix) = self
+                .git_repositories
+                .binary_search_by_key(&parent_path, |repo| repo.git_dir_path.as_ref())
+            {
+                self.git_repositories[ix].scan_id = self.snapshot.scan_id;
+            }
         }
     }
 
@@ -1532,6 +1693,22 @@ impl LocalSnapshot {
 
         ignore_stack
     }
+
+    pub fn git_repo_entries(&self) -> &[GitRepositoryEntry] {
+        &self.git_repositories
+    }
+}
+
+impl GitRepositoryEntry {
+    // Note that these paths should be relative to the worktree root.
+    pub(crate) fn manages(&self, path: &Path) -> bool {
+        path.starts_with(self.content_path.as_ref())
+    }
+
+    // Note that theis path should be relative to the worktree root.
+    pub(crate) fn in_dot_git(&self, path: &Path) -> bool {
+        path.starts_with(self.git_dir_path.as_ref())
+    }
 }
 
 async fn build_gitignore(abs_path: &Path, fs: &dyn Fs) -> Result<Gitignore> {
@@ -2244,9 +2421,12 @@ impl BackgroundScanner {
             new_entries.push(child_entry);
         }
 
-        self.snapshot
-            .lock()
-            .populate_dir(job.path.clone(), new_entries, new_ignore);
+        self.snapshot.lock().populate_dir(
+            job.path.clone(),
+            new_entries,
+            new_ignore,
+            self.fs.as_ref(),
+        );
         for new_job in new_jobs {
             job.scan_queue.send(new_job).await.unwrap();
         }
@@ -2321,6 +2501,11 @@ impl BackgroundScanner {
                         fs_entry.is_ignored = ignore_stack.is_all();
                         snapshot.insert_entry(fs_entry, self.fs.as_ref());
 
+                        let scan_id = snapshot.scan_id;
+                        if let Some(repo) = snapshot.in_dot_git(&path) {
+                            repo.scan_id = scan_id;
+                        }
+
                         let mut ancestor_inodes = snapshot.ancestor_inodes_for_path(&path);
                         if metadata.is_dir && !ancestor_inodes.contains(&metadata.inode) {
                             ancestor_inodes.insert(metadata.inode);
@@ -2367,6 +2552,7 @@ impl BackgroundScanner {
         self.snapshot.lock().removed_entry_ids.clear();
 
         self.update_ignore_statuses().await;
+        self.update_git_repositories();
         true
     }
 
@@ -2432,6 +2618,13 @@ impl BackgroundScanner {
             .await;
     }
 
+    fn update_git_repositories(&self) {
+        let mut snapshot = self.snapshot.lock();
+        let mut git_repositories = mem::take(&mut snapshot.git_repositories);
+        git_repositories.retain(|repo| snapshot.entry_for_path(&repo.git_dir_path).is_some());
+        snapshot.git_repositories = git_repositories;
+    }
+
     async fn update_ignore_status(&self, job: UpdateIgnoreStatusJob, snapshot: &LocalSnapshot) {
         let mut ignore_stack = job.ignore_stack;
         if let Some((ignore, _)) = snapshot.ignores_by_parent_abs_path.get(&job.abs_path) {
@@ -2778,6 +2971,7 @@ mod tests {
     use anyhow::Result;
     use client::test::FakeHttpClient;
     use fs::RealFs;
+    use git::repository::FakeGitRepository;
     use gpui::{executor::Deterministic, TestAppContext};
     use rand::prelude::*;
     use serde_json::json;
@@ -2786,6 +2980,7 @@ mod tests {
         fmt::Write,
         time::{SystemTime, UNIX_EPOCH},
     };
+
     use util::test::temp_tree;
 
     #[gpui::test]
@@ -3005,6 +3200,135 @@ mod tests {
         });
     }
 
+    #[gpui::test]
+    async fn test_git_repository_for_path(cx: &mut TestAppContext) {
+        let root = temp_tree(json!({
+            "dir1": {
+                ".git": {},
+                "deps": {
+                    "dep1": {
+                        ".git": {},
+                        "src": {
+                            "a.txt": ""
+                        }
+                    }
+                },
+                "src": {
+                    "b.txt": ""
+                }
+            },
+            "c.txt": "",
+
+        }));
+
+        let http_client = FakeHttpClient::with_404_response();
+        let client = cx.read(|cx| Client::new(http_client, cx));
+        let tree = Worktree::local(
+            client,
+            root.path(),
+            true,
+            Arc::new(RealFs),
+            Default::default(),
+            &mut cx.to_async(),
+        )
+        .await
+        .unwrap();
+
+        cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
+            .await;
+        tree.flush_fs_events(cx).await;
+
+        tree.read_with(cx, |tree, _cx| {
+            let tree = tree.as_local().unwrap();
+
+            assert!(tree.repo_for("c.txt".as_ref()).is_none());
+
+            let repo = tree.repo_for("dir1/src/b.txt".as_ref()).unwrap();
+            assert_eq!(repo.content_path.as_ref(), Path::new("dir1"));
+            assert_eq!(repo.git_dir_path.as_ref(), Path::new("dir1/.git"));
+
+            let repo = tree.repo_for("dir1/deps/dep1/src/a.txt".as_ref()).unwrap();
+            assert_eq!(repo.content_path.as_ref(), Path::new("dir1/deps/dep1"));
+            assert_eq!(repo.git_dir_path.as_ref(), Path::new("dir1/deps/dep1/.git"),);
+        });
+
+        let original_scan_id = tree.read_with(cx, |tree, _cx| {
+            let tree = tree.as_local().unwrap();
+            tree.repo_for("dir1/src/b.txt".as_ref()).unwrap().scan_id
+        });
+
+        std::fs::write(root.path().join("dir1/.git/random_new_file"), "hello").unwrap();
+        tree.flush_fs_events(cx).await;
+
+        tree.read_with(cx, |tree, _cx| {
+            let tree = tree.as_local().unwrap();
+            let new_scan_id = tree.repo_for("dir1/src/b.txt".as_ref()).unwrap().scan_id;
+            assert_ne!(
+                original_scan_id, new_scan_id,
+                "original {original_scan_id}, new {new_scan_id}"
+            );
+        });
+
+        std::fs::remove_dir_all(root.path().join("dir1/.git")).unwrap();
+        tree.flush_fs_events(cx).await;
+
+        tree.read_with(cx, |tree, _cx| {
+            let tree = tree.as_local().unwrap();
+
+            assert!(tree.repo_for("dir1/src/b.txt".as_ref()).is_none());
+        });
+    }
+
+    #[test]
+    fn test_changed_repos() {
+        fn fake_entry(git_dir_path: impl AsRef<Path>, scan_id: usize) -> GitRepositoryEntry {
+            GitRepositoryEntry {
+                repo: Arc::new(Mutex::new(FakeGitRepository::default())),
+                scan_id,
+                content_path: git_dir_path.as_ref().parent().unwrap().into(),
+                git_dir_path: git_dir_path.as_ref().into(),
+            }
+        }
+
+        let prev_repos: Vec<GitRepositoryEntry> = vec![
+            fake_entry("/.git", 0),
+            fake_entry("/a/.git", 0),
+            fake_entry("/a/b/.git", 0),
+        ];
+
+        let new_repos: Vec<GitRepositoryEntry> = vec![
+            fake_entry("/a/.git", 1),
+            fake_entry("/a/b/.git", 0),
+            fake_entry("/a/c/.git", 0),
+        ];
+
+        let res = LocalWorktree::changed_repos(&prev_repos, &new_repos);
+
+        // Deletion retained
+        assert!(res
+            .iter()
+            .find(|repo| repo.git_dir_path.as_ref() == Path::new("/.git") && repo.scan_id == 0)
+            .is_some());
+
+        // Update retained
+        assert!(res
+            .iter()
+            .find(|repo| repo.git_dir_path.as_ref() == Path::new("/a/.git") && repo.scan_id == 1)
+            .is_some());
+
+        // Addition retained
+        assert!(res
+            .iter()
+            .find(|repo| repo.git_dir_path.as_ref() == Path::new("/a/c/.git") && repo.scan_id == 0)
+            .is_some());
+
+        // Nochange, not retained
+        assert!(res
+            .iter()
+            .find(|repo| repo.git_dir_path.as_ref() == Path::new("/a/b/.git") && repo.scan_id == 0)
+            .is_none());
+    }
+
     #[gpui::test]
     async fn test_write_file(cx: &mut TestAppContext) {
         let dir = temp_tree(json!({
@@ -3123,6 +3447,7 @@ mod tests {
             abs_path: root_dir.path().into(),
             removed_entry_ids: Default::default(),
             ignores_by_parent_abs_path: Default::default(),
+            git_repositories: Default::default(),
             next_entry_id: next_entry_id.clone(),
             snapshot: Snapshot {
                 id: WorktreeId::from_usize(0),

crates/rpc/proto/zed.proto 🔗

@@ -108,9 +108,9 @@ message Envelope {
         FollowResponse follow_response = 93;
         UpdateFollowers update_followers = 94;
         Unfollow unfollow = 95;
-
         GetPrivateUserInfo get_private_user_info = 96;
         GetPrivateUserInfoResponse get_private_user_info_response = 97;
+        UpdateDiffBase update_diff_base = 98;
     }
 }
 
@@ -831,7 +831,8 @@ message BufferState {
     uint64 id = 1;
     optional File file = 2;
     string base_text = 3;
-    LineEnding line_ending = 4;
+    optional string diff_base = 4;
+    LineEnding line_ending = 5;
 }
 
 message BufferChunk {
@@ -1001,3 +1002,9 @@ message WorktreeMetadata {
     string root_name = 2;
     bool visible = 3;
 }
+
+message UpdateDiffBase {
+    uint64 project_id = 1;
+    uint64 buffer_id = 2;
+    optional string diff_base = 3;
+}

crates/rpc/src/proto.rs 🔗

@@ -167,6 +167,7 @@ messages!(
     (UpdateProject, Foreground),
     (UpdateWorktree, Foreground),
     (UpdateWorktreeExtensions, Background),
+    (UpdateDiffBase, Background),
     (GetPrivateUserInfo, Foreground),
     (GetPrivateUserInfoResponse, Foreground),
 );
@@ -266,6 +267,7 @@ entity_messages!(
     UpdateProject,
     UpdateWorktree,
     UpdateWorktreeExtensions,
+    UpdateDiffBase
 );
 
 entity_messages!(channel_id, ChannelMessageSent);

crates/rpc/src/rpc.rs 🔗

@@ -6,4 +6,4 @@ pub use conn::Connection;
 pub use peer::*;
 mod macros;
 
-pub const PROTOCOL_VERSION: u32 = 32;
+pub const PROTOCOL_VERSION: u32 = 34;

crates/settings/src/settings.rs 🔗

@@ -32,6 +32,8 @@ pub struct Settings {
     pub default_dock_anchor: DockAnchor,
     pub editor_defaults: EditorSettings,
     pub editor_overrides: EditorSettings,
+    pub git: GitSettings,
+    pub git_overrides: GitSettings,
     pub terminal_defaults: TerminalSettings,
     pub terminal_overrides: TerminalSettings,
     pub language_defaults: HashMap<Arc<str>, EditorSettings>,
@@ -52,6 +54,22 @@ impl FeatureFlags {
     }
 }
 
+#[derive(Copy, Clone, Debug, Default, Deserialize, JsonSchema)]
+pub struct GitSettings {
+    pub git_gutter: Option<GitGutter>,
+    pub gutter_debounce: Option<u64>,
+}
+
+#[derive(Clone, Copy, Debug, Default, Deserialize, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum GitGutter {
+    #[default]
+    TrackedFiles,
+    Hide,
+}
+
+pub struct GitGutterConfig {}
+
 #[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
 pub struct EditorSettings {
     pub tab_size: Option<NonZeroU32>,
@@ -196,6 +214,8 @@ pub struct SettingsFileContent {
     #[serde(default)]
     pub terminal: TerminalSettings,
     #[serde(default)]
+    pub git: Option<GitSettings>,
+    #[serde(default)]
     #[serde(alias = "language_overrides")]
     pub languages: HashMap<Arc<str>, EditorSettings>,
     #[serde(default)]
@@ -252,6 +272,8 @@ impl Settings {
                 enable_language_server: required(defaults.editor.enable_language_server),
             },
             editor_overrides: Default::default(),
+            git: defaults.git.unwrap(),
+            git_overrides: Default::default(),
             terminal_defaults: Default::default(),
             terminal_overrides: Default::default(),
             language_defaults: defaults.languages,
@@ -303,6 +325,7 @@ impl Settings {
         }
 
         self.editor_overrides = data.editor;
+        self.git_overrides = data.git.unwrap_or_default();
         self.terminal_defaults.font_size = data.terminal.font_size;
         self.terminal_overrides = data.terminal;
         self.language_overrides = data.languages;
@@ -358,6 +381,14 @@ impl Settings {
             .expect("missing default")
     }
 
+    pub fn git_gutter(&self) -> GitGutter {
+        self.git_overrides.git_gutter.unwrap_or_else(|| {
+            self.git
+                .git_gutter
+                .expect("git_gutter should be some by setting setup")
+        })
+    }
+
     #[cfg(any(test, feature = "test-support"))]
     pub fn test(cx: &gpui::AppContext) -> Settings {
         Settings {
@@ -382,6 +413,8 @@ impl Settings {
             editor_overrides: Default::default(),
             terminal_defaults: Default::default(),
             terminal_overrides: Default::default(),
+            git: Default::default(),
+            git_overrides: Default::default(),
             language_defaults: Default::default(),
             language_overrides: Default::default(),
             lsp: Default::default(),

crates/sum_tree/src/sum_tree.rs 🔗

@@ -101,6 +101,12 @@ pub enum Bias {
     Right,
 }
 
+impl Default for Bias {
+    fn default() -> Self {
+        Bias::Left
+    }
+}
+
 impl PartialOrd for Bias {
     fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
         Some(self.cmp(other))

crates/text/src/anchor.rs 🔗

@@ -4,7 +4,7 @@ use anyhow::Result;
 use std::{cmp::Ordering, fmt::Debug, ops::Range};
 use sum_tree::Bias;
 
-#[derive(Copy, Clone, Eq, PartialEq, Debug, Hash)]
+#[derive(Copy, Clone, Eq, PartialEq, Debug, Hash, Default)]
 pub struct Anchor {
     pub timestamp: clock::Local,
     pub offset: usize,

crates/text/src/rope.rs 🔗

@@ -54,6 +54,13 @@ impl Rope {
         cursor.slice(range.end)
     }
 
+    pub fn slice_rows(&self, range: Range<u32>) -> Rope {
+        //This would be more efficient with a forward advance after the first, but it's fine
+        let start = self.point_to_offset(Point::new(range.start, 0));
+        let end = self.point_to_offset(Point::new(range.end, 0));
+        self.slice(start..end)
+    }
+
     pub fn push(&mut self, text: &str) {
         let mut new_chunks = SmallVec::<[_; 16]>::new();
         let mut new_chunk = ArrayString::new();

crates/theme/src/theme.rs 🔗

@@ -488,8 +488,7 @@ pub struct Editor {
     pub rename_fade: f32,
     pub document_highlight_read_background: Color,
     pub document_highlight_write_background: Color,
-    pub diff_background_deleted: Color,
-    pub diff_background_inserted: Color,
+    pub diff: DiffStyle,
     pub line_number: Color,
     pub line_number_active: Color,
     pub guest_selections: Vec<SelectionStyle>,
@@ -573,6 +572,16 @@ pub struct CodeActions {
     pub vertical_scale: f32,
 }
 
+#[derive(Clone, Deserialize, Default)]
+pub struct DiffStyle {
+    pub inserted: Color,
+    pub modified: Color,
+    pub deleted: Color,
+    pub removed_width_em: f32,
+    pub width_em: f32,
+    pub corner_radius: f32,
+}
+
 #[derive(Debug, Default, Clone, Copy)]
 pub struct Interactive<T> {
     pub default: T,

crates/util/Cargo.toml 🔗

@@ -7,17 +7,21 @@ edition = "2021"
 doctest = false
 
 [features]
-test-support = ["rand", "serde_json", "tempdir"]
+test-support = ["rand", "serde_json", "tempdir", "git2"]
 
 [dependencies]
 anyhow = "1.0.38"
 futures = "0.3"
 log = { version = "0.4.16", features = ["kv_unstable_serde"] }
+lazy_static = "1.4.0"
 rand = { version = "0.8", optional = true }
 tempdir = { version = "0.3.7", optional = true }
 serde_json = { version = "1.0", features = ["preserve_order"], optional = true }
+git2 = { version = "0.15", default-features = false, optional = true }
+
 
 [dev-dependencies]
 rand = { version = "0.8" }
 tempdir = { version = "0.3.7" }
 serde_json = { version = "1.0", features = ["preserve_order"] }
+git2 = { version = "0.15", default-features = false }

crates/util/src/test.rs 🔗

@@ -1,7 +1,11 @@
 mod assertions;
 mod marked_text;
 
-use std::path::{Path, PathBuf};
+use git2;
+use std::{
+    ffi::OsStr,
+    path::{Path, PathBuf},
+};
 use tempdir::TempDir;
 
 pub use assertions::*;
@@ -24,6 +28,11 @@ fn write_tree(path: &Path, tree: serde_json::Value) {
             match contents {
                 Value::Object(_) => {
                     fs::create_dir(&path).unwrap();
+
+                    if path.file_name() == Some(&OsStr::new(".git")) {
+                        git2::Repository::init(&path.parent().unwrap()).unwrap();
+                    }
+
                     write_tree(&path, contents);
                 }
                 Value::Null => {

crates/workspace/src/workspace.rs 🔗

@@ -52,7 +52,6 @@ use std::{
     cell::RefCell,
     fmt,
     future::Future,
-    mem,
     ops::Range,
     path::{Path, PathBuf},
     rc::Rc,
@@ -318,7 +317,23 @@ pub trait Item: View {
         project: ModelHandle<Project>,
         cx: &mut ViewContext<Self>,
     ) -> Task<Result<()>>;
+    fn git_diff_recalc(
+        &mut self,
+        _project: ModelHandle<Project>,
+        _cx: &mut ViewContext<Self>,
+    ) -> Task<Result<()>> {
+        Task::ready(Ok(()))
+    }
     fn to_item_events(event: &Self::Event) -> Vec<ItemEvent>;
+    fn should_close_item_on_event(_: &Self::Event) -> bool {
+        false
+    }
+    fn should_update_tab_on_event(_: &Self::Event) -> bool {
+        false
+    }
+    fn is_edit_event(_: &Self::Event) -> bool {
+        false
+    }
     fn act_as_type(
         &self,
         type_id: TypeId,
@@ -435,6 +450,57 @@ impl<T: FollowableItem> FollowableItemHandle for ViewHandle<T> {
     }
 }
 
+struct DelayedDebouncedEditAction {
+    task: Option<Task<()>>,
+    cancel_channel: Option<oneshot::Sender<()>>,
+}
+
+impl DelayedDebouncedEditAction {
+    fn new() -> DelayedDebouncedEditAction {
+        DelayedDebouncedEditAction {
+            task: None,
+            cancel_channel: None,
+        }
+    }
+
+    fn fire_new<F, Fut>(
+        &mut self,
+        delay: Duration,
+        workspace: &Workspace,
+        cx: &mut ViewContext<Workspace>,
+        f: F,
+    ) where
+        F: FnOnce(ModelHandle<Project>, AsyncAppContext) -> Fut + 'static,
+        Fut: 'static + Future<Output = ()>,
+    {
+        if let Some(channel) = self.cancel_channel.take() {
+            _ = channel.send(());
+        }
+
+        let project = workspace.project().downgrade();
+
+        let (sender, mut receiver) = oneshot::channel::<()>();
+        self.cancel_channel = Some(sender);
+
+        let previous_task = self.task.take();
+        self.task = Some(cx.spawn_weak(|_, cx| async move {
+            let mut timer = cx.background().timer(delay).fuse();
+            if let Some(previous_task) = previous_task {
+                previous_task.await;
+            }
+
+            futures::select_biased! {
+                _ = receiver => return,
+                _ = timer => {}
+            }
+
+            if let Some(project) = project.upgrade(&cx) {
+                (f)(project, cx).await;
+            }
+        }));
+    }
+}
+
 pub trait ItemHandle: 'static + fmt::Debug {
     fn subscribe_to_item_events(
         &self,
@@ -473,6 +539,11 @@ pub trait ItemHandle: 'static + fmt::Debug {
     ) -> Task<Result<()>>;
     fn reload(&self, project: ModelHandle<Project>, cx: &mut MutableAppContext)
         -> Task<Result<()>>;
+    fn git_diff_recalc(
+        &self,
+        project: ModelHandle<Project>,
+        cx: &mut MutableAppContext,
+    ) -> Task<Result<()>>;
     fn act_as_type(&self, type_id: TypeId, cx: &AppContext) -> Option<AnyViewHandle>;
     fn to_followable_item_handle(&self, cx: &AppContext) -> Option<Box<dyn FollowableItemHandle>>;
     fn on_release(
@@ -578,8 +649,8 @@ impl<T: Item> ItemHandle for ViewHandle<T> {
             .insert(self.id(), pane.downgrade())
             .is_none()
         {
-            let mut pending_autosave = None;
-            let mut cancel_pending_autosave = oneshot::channel::<()>().0;
+            let mut pending_autosave = DelayedDebouncedEditAction::new();
+            let mut pending_git_update = DelayedDebouncedEditAction::new();
             let pending_update = Rc::new(RefCell::new(None));
             let pending_update_scheduled = Rc::new(AtomicBool::new(false));
 
@@ -637,45 +708,66 @@ impl<T: Item> ItemHandle for ViewHandle<T> {
                                     .detach_and_log_err(cx);
                                 return;
                             }
+
                             ItemEvent::UpdateTab => {
                                 pane.update(cx, |_, cx| {
                                     cx.emit(pane::Event::ChangeItemTitle);
                                     cx.notify();
                                 });
                             }
+
                             ItemEvent::Edit => {
                                 if let Autosave::AfterDelay { milliseconds } =
                                     cx.global::<Settings>().autosave
                                 {
-                                    let prev_autosave = pending_autosave
-                                        .take()
-                                        .unwrap_or_else(|| Task::ready(Some(())));
-                                    let (cancel_tx, mut cancel_rx) = oneshot::channel::<()>();
-                                    let prev_cancel_tx =
-                                        mem::replace(&mut cancel_pending_autosave, cancel_tx);
-                                    let project = workspace.project.downgrade();
-                                    let _ = prev_cancel_tx.send(());
+                                    let delay = Duration::from_millis(milliseconds);
                                     let item = item.clone();
-                                    pending_autosave =
-                                        Some(cx.spawn_weak(|_, mut cx| async move {
-                                            let mut timer = cx
-                                                .background()
-                                                .timer(Duration::from_millis(milliseconds))
-                                                .fuse();
-                                            prev_autosave.await;
-                                            futures::select_biased! {
-                                                _ = cancel_rx => return None,
-                                                    _ = timer => {}
-                                            }
-
-                                            let project = project.upgrade(&cx)?;
+                                    pending_autosave.fire_new(
+                                        delay,
+                                        workspace,
+                                        cx,
+                                        |project, mut cx| async move {
                                             cx.update(|cx| Pane::autosave_item(&item, project, cx))
                                                 .await
                                                 .log_err();
-                                            None
-                                        }));
+                                        },
+                                    );
+                                }
+
+                                let settings = cx.global::<Settings>();
+                                let debounce_delay = settings.git_overrides.gutter_debounce;
+
+                                let item = item.clone();
+
+                                if let Some(delay) = debounce_delay {
+                                    const MIN_GIT_DELAY: u64 = 50;
+
+                                    let delay = delay.max(MIN_GIT_DELAY);
+                                    let duration = Duration::from_millis(delay);
+
+                                    pending_git_update.fire_new(
+                                        duration,
+                                        workspace,
+                                        cx,
+                                        |project, mut cx| async move {
+                                            cx.update(|cx| item.git_diff_recalc(project, cx))
+                                                .await
+                                                .log_err();
+                                        },
+                                    );
+                                } else {
+                                    let project = workspace.project().downgrade();
+                                    cx.spawn_weak(|_, mut cx| async move {
+                                        if let Some(project) = project.upgrade(&cx) {
+                                            cx.update(|cx| item.git_diff_recalc(project, cx))
+                                                .await
+                                                .log_err();
+                                        }
+                                    })
+                                    .detach();
                                 }
                             }
+
                             _ => {}
                         }
                     }
@@ -755,6 +847,14 @@ impl<T: Item> ItemHandle for ViewHandle<T> {
         self.update(cx, |item, cx| item.reload(project, cx))
     }
 
+    fn git_diff_recalc(
+        &self,
+        project: ModelHandle<Project>,
+        cx: &mut MutableAppContext,
+    ) -> Task<Result<()>> {
+        self.update(cx, |item, cx| item.git_diff_recalc(project, cx))
+    }
+
     fn act_as_type(&self, type_id: TypeId, cx: &AppContext) -> Option<AnyViewHandle> {
         self.read(cx).act_as_type(type_id, self, cx)
     }

styles/src/styleTree/editor.ts 🔗

@@ -7,6 +7,7 @@ import {
   player,
   popoverShadow,
   text,
+  textColor,
   TextColor,
 } from "./components";
 import hoverPopover from "./hoverPopover";
@@ -59,8 +60,14 @@ export default function editor(theme: Theme) {
       indicator: iconColor(theme, "secondary"),
       verticalScale: 0.618
     },
-    diffBackgroundDeleted: backgroundColor(theme, "error"),
-    diffBackgroundInserted: backgroundColor(theme, "ok"),
+    diff: {
+      deleted: theme.iconColor.error,
+      inserted: theme.iconColor.ok,
+      modified: theme.iconColor.warning,
+      removedWidthEm: 0.275,
+      widthEm: 0.16,
+      cornerRadius: 0.05,
+    },
     documentHighlightReadBackground: theme.editor.highlight.occurrence,
     documentHighlightWriteBackground: theme.editor.highlight.activeOccurrence,
     errorColor: theme.textColor.error,

styles/src/themes/common/base16.ts 🔗

@@ -113,6 +113,11 @@ export function createTheme(
       hovered: sample(ramps.blue, 0.1),
       active: sample(ramps.blue, 0.15),
     },
+    on500Ok: {
+      base: sample(ramps.green, 0.05),
+      hovered: sample(ramps.green, 0.1),
+      active: sample(ramps.green, 0.15)
+    }
   };
 
   const borderColor = {

styles/src/themes/common/theme.ts 🔗

@@ -78,6 +78,7 @@ export default interface Theme {
     // Hacks for elements on top of the editor
     on500: BackgroundColorSet;
     ok: BackgroundColorSet;
+    on500Ok: BackgroundColorSet;
     error: BackgroundColorSet;
     on500Error: BackgroundColorSet;
     warning: BackgroundColorSet;