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}