Skip to main content

lattice_host/vcs/
mod.rs

1//! Layer 2 VCS subsystem โ€” auto-inline-diff against HEAD.
2//!
3//! Automatically registers a `DiffSession` with a `GitBaseline`
4//! (file content at HEAD) when a file is opened inside a git
5//! repository. Gutter signs appear immediately, showing changes
6//! since the last commit.
7//!
8//! Controlled by the `git.auto-head-diff` typed option.
9//!
10//! See `docs/dev/architecture/magit.md` ยง3 (three-layer model)
11//! and `docs/dev/operations/slice-plans/magit.md` VCS.2.
12
13use std::collections::HashMap;
14use std::sync::Arc;
15
16use lattice_core::BufferId;
17use lattice_diff::subsystem::{DiffDescriptor, DiffSubsystem};
18use lattice_protocol::event::{Event, EventKind};
19use lattice_runtime::EventBus;
20use lattice_vcs::Repository;
21
22use crate::diff::subsystem::DocumentBufferResolver;
23
24use self::baseline::GitBaseline;
25
26pub mod baseline;
27pub mod options;
28
29/// Tracks an auto-registered diff session for a single buffer.
30/// The `BufferId` is the map key; the struct exists as a future extension
31/// point for per-session metadata (repo path, last-known HEAD, etc.).
32#[allow(dead_code)]
33struct TrackedSession {
34    buffer_id: BufferId,
35}
36
37/// The VCS subsystem โ€” watches for document open/close events and
38/// auto-registers diff sessions against git HEAD.
39pub struct VcsSubsystem {
40    sessions: std::sync::Mutex<HashMap<BufferId, TrackedSession>>,
41}
42
43/// Dropped on editor teardown; unsubscribes the event bus and aborts
44/// the drainer task.
45#[derive(Debug)]
46pub struct VcsSubscriptionGuard {
47    bus: Arc<EventBus>,
48    subscription: lattice_runtime::SubscriptionId,
49    drainer: tokio::task::JoinHandle<()>,
50}
51
52impl Drop for VcsSubscriptionGuard {
53    fn drop(&mut self) {
54        self.bus.unsubscribe(self.subscription);
55        self.drainer.abort();
56    }
57}
58
59impl Default for VcsSubsystem {
60    fn default() -> Self {
61        Self::new()
62    }
63}
64
65impl VcsSubsystem {
66    pub fn new() -> Self {
67        Self {
68            sessions: std::sync::Mutex::new(HashMap::new()),
69        }
70    }
71
72    /// Bind the subsystem to the event bus. Spawns a drainer task
73    /// that listens for `DocumentOpened` / `DocumentClosed` events
74    /// and auto-registers / tears down diff sessions.
75    ///
76    /// Returns a guard whose `Drop` cleans up the subscription + task.
77    pub fn bind(
78        self: Arc<Self>,
79        bus: Arc<EventBus>,
80        diff_subsystem: Arc<DiffSubsystem>,
81        config: Arc<lattice_config::ConfigRegistry>,
82        resolver: Arc<dyn DocumentBufferResolver>,
83    ) -> VcsSubscriptionGuard {
84        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Event>();
85
86        let subscription = bus.subscribe(
87            lattice_runtime::EventFilter::kinds(vec![
88                EventKind::DocumentOpened,
89                EventKind::DocumentClosed,
90            ]),
91            lattice_runtime::SubscriptionTarget::Channel(tx),
92        );
93
94        let drainer = tokio::task::spawn(async move {
95            while let Some(event) = rx.recv().await {
96                match event {
97                    Event::DocumentOpened { id, path, .. } => {
98                        let Some(path) = path else { continue };
99
100                        // Check option
101                        let auto = config
102                            .get_typed::<options::GitAutoHeadDiff>()
103                            .map(|v| *v)
104                            .unwrap_or(true);
105                        if !auto {
106                            continue;
107                        }
108
109                        let Some(buffer_id) = resolver.buffer_id_for(id) else {
110                            continue;
111                        };
112
113                        // Discover git repo. `gix::discover` requires a
114                        // directory โ€” `path` is the opened file itself, so
115                        // start from its parent (bug fix: this previously
116                        // always failed with "not a directory", meaning
117                        // auto-head-diff gutter signs never appeared for
118                        // any file).
119                        let Some(parent) = path.parent() else {
120                            continue;
121                        };
122                        let repo = match Repository::discover(parent) {
123                            Ok(r) => r,
124                            Err(_) => continue, // not in a git repo
125                        };
126
127                        let workdir = match repo.workdir() {
128                            Some(w) => w.to_path_buf(),
129                            None => continue,
130                        };
131
132                        // Compute path relative to repo root
133                        let rel_path = match path.strip_prefix(&workdir) {
134                            Ok(p) => p.to_path_buf(),
135                            Err(_) => continue,
136                        };
137
138                        // Build GitBaseline source
139                        let source = Arc::new(GitBaseline::new(workdir, "HEAD", rel_path));
140
141                        // Register diff session
142                        let descriptor = DiffDescriptor {
143                            sources: vec![source],
144                            watch: vec![buffer_id],
145                            participants: vec![buffer_id],
146                        };
147
148                        diff_subsystem.register_with_sources(
149                            buffer_id,
150                            lattice_diff::DiffAlgorithm::Histogram,
151                            descriptor,
152                        );
153
154                        // Track
155                        if let Ok(mut sessions) = self.sessions.lock() {
156                            sessions.insert(buffer_id, TrackedSession { buffer_id });
157                        }
158                    }
159                    Event::DocumentClosed { id } => {
160                        let Some(buffer_id) = resolver.buffer_id_for(id) else {
161                            continue;
162                        };
163                        if let Ok(mut sessions) = self.sessions.lock()
164                            && sessions.remove(&buffer_id).is_some()
165                        {
166                            diff_subsystem.drop_session(buffer_id);
167                        }
168                    }
169                    _ => {}
170                }
171            }
172        });
173
174        VcsSubscriptionGuard {
175            bus,
176            subscription,
177            drainer,
178        }
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    // Tests for the VCS subsystem live in `editor.rs` (integration)
185    // alongside the diff subsystem tests, since they require a full
186    // Editor + BufferRegistry + diff subsystem setup.
187}