Skip to main content

lds_git/
read.rs

1//! Read-only inspection: status, log, diff, worktree list.
2//!
3//! Everything here is safe to call without ownership checks — these methods
4//! only observe the repository state.
5//!
6//! libgit2 (`Repository::open` and every operation reachable from it) is a
7//! synchronous C library — a `Repository` handle is not `Send`, and any call
8//! that walks refs / statuses / diffs will block the calling thread on FS
9//! reads. We therefore run every libgit2 fragment inside
10//! [`tokio::task::spawn_blocking`] so it never occupies an async worker
11//! thread; parallel MCP tool calls stay responsive even when one caller is
12//! chewing through a large repository on a slow disk.
13
14use std::path::PathBuf;
15use std::sync::Arc;
16
17use anyhow::{Context, Result};
18use git2::{DiffOptions, Repository, Status, StatusOptions};
19use lds_core::Session;
20
21use crate::output::{
22    CommitEntry, DiffOutput, EntryStatus, LogOutput, StatusKind, StatusOutput, WorktreeEntry,
23    WorktreeListOutput,
24};
25use crate::{GitModule, TIMEOUT_LOCAL, git_cmd};
26
27/// Optional narrowing for [`GitModule::log`]. All fields are AND-combined; an
28/// absent field imposes no constraint.
29#[derive(Debug, Clone, Default)]
30pub struct LogFilters {
31    /// Cap on how many matching commits to return (post-filter).
32    pub max_count: usize,
33    /// Case-sensitive substring match against `"Name <email>"`.
34    pub author: Option<String>,
35    /// Git pathspec entries; a commit is kept when its diff against its first
36    /// parent (or against the empty tree for the root commit) touches at least
37    /// one entry. Empty vec is treated as "no path filter".
38    pub paths: Option<Vec<String>>,
39    /// Keep only commits whose author time is `>=` this value (unix epoch
40    /// seconds).
41    pub since: Option<i64>,
42    /// Case-sensitive substring match against the full commit message
43    /// (subject + body).
44    pub grep: Option<String>,
45}
46
47impl GitModule {
48    /// Inspect the working tree and produce a [`StatusOutput`] split into
49    /// staged / unstaged / untracked buckets.
50    ///
51    /// Runs the libgit2 work on the [`tokio::task::spawn_blocking`] pool.
52    pub async fn status(&self) -> Result<StatusOutput> {
53        let session = Arc::clone(&self.session);
54        blocking(move || status_sync(&session)).await
55    }
56
57    /// Walk HEAD backwards, applying [`LogFilters`] and returning at most
58    /// `filters.max_count` matching commits.
59    ///
60    /// Runs the libgit2 revwalk on the [`tokio::task::spawn_blocking`] pool.
61    pub async fn log(&self, filters: LogFilters) -> Result<LogOutput> {
62        let session = Arc::clone(&self.session);
63        blocking(move || log_sync(&session, filters)).await
64    }
65
66    /// Produce the patch for either the staged (HEAD vs index) or the
67    /// unstaged (index vs worktree) view, controlled by `staged`.
68    ///
69    /// Runs the libgit2 diff on the [`tokio::task::spawn_blocking`] pool.
70    pub async fn diff(&self, staged: bool) -> Result<DiffOutput> {
71        let session = Arc::clone(&self.session);
72        blocking(move || diff_sync(&session, staged)).await
73    }
74
75    /// List every linked worktree, annotated with session-ownership.
76    pub async fn worktree_list(&self) -> Result<WorktreeListOutput> {
77        let raw = git_cmd(
78            self.session().root(),
79            &["worktree", "list", "--porcelain"],
80            TIMEOUT_LOCAL,
81        )
82        .await?;
83
84        let mut worktrees = Vec::new();
85        let mut current_path: Option<PathBuf> = None;
86        let mut current_head: Option<String> = None;
87        let mut current_branch: Option<String> = None;
88
89        for line in raw.lines() {
90            if line.is_empty() {
91                if let Some(p) = current_path.take() {
92                    worktrees.push(self.make_worktree_entry(
93                        p,
94                        current_head.take(),
95                        current_branch.take(),
96                    ));
97                }
98                continue;
99            }
100            if let Some(p) = line.strip_prefix("worktree ") {
101                current_path = Some(PathBuf::from(p));
102            } else if let Some(h) = line.strip_prefix("HEAD ") {
103                current_head = Some(h.to_string());
104            } else if let Some(b) = line.strip_prefix("branch ") {
105                current_branch = Some(b.trim_start_matches("refs/heads/").to_string());
106            }
107        }
108        if let Some(p) = current_path {
109            worktrees.push(self.make_worktree_entry(p, current_head, current_branch));
110        }
111
112        Ok(WorktreeListOutput { worktrees })
113    }
114
115    fn make_worktree_entry(
116        &self,
117        path: PathBuf,
118        head: Option<String>,
119        branch: Option<String>,
120    ) -> WorktreeEntry {
121        let owned = self.owned_worktrees.contains(&path)
122            || path
123                .canonicalize()
124                .map(|c| self.owned_worktrees.contains(&c))
125                .unwrap_or(false);
126        WorktreeEntry {
127            path,
128            head,
129            branch,
130            owned,
131        }
132    }
133}
134
135/// Run `f` on the [`tokio::task::spawn_blocking`] pool and unwrap the
136/// [`tokio::task::JoinError`] with a stable context message. The inner
137/// `Result` is preserved verbatim.
138pub(crate) async fn blocking<F, T>(f: F) -> Result<T>
139where
140    F: FnOnce() -> Result<T> + Send + 'static,
141    T: Send + 'static,
142{
143    tokio::task::spawn_blocking(f)
144        .await
145        .context("spawn_blocking join failed")?
146}
147
148fn status_sync(session: &Session) -> Result<StatusOutput> {
149    let repo = Repository::open(session.root())?;
150
151    let head_sha = match repo.head() {
152        Ok(head) => head.target().map(|oid| oid.to_string()),
153        Err(e) if e.code() == git2::ErrorCode::UnbornBranch => None,
154        Err(e) => return Err(e.into()),
155    };
156
157    let branch = match repo.head() {
158        Ok(head) => head.shorthand().map(|s| s.to_string()),
159        Err(e) if e.code() == git2::ErrorCode::UnbornBranch => None,
160        Err(e) => return Err(e.into()),
161    };
162
163    let mut opts = StatusOptions::new();
164    opts.include_untracked(true).recurse_untracked_dirs(true);
165    let statuses = repo.statuses(Some(&mut opts))?;
166
167    let mut staged = Vec::new();
168    let mut unstaged = Vec::new();
169    let mut untracked = Vec::new();
170
171    for entry in statuses.iter() {
172        let path = match entry.path() {
173            Some(p) => PathBuf::from(p),
174            None => continue,
175        };
176        let st = entry.status();
177
178        if st.intersects(Status::WT_NEW) {
179            untracked.push(path.clone());
180        }
181
182        if let Some(kind) = staged_kind(st) {
183            staged.push(EntryStatus {
184                path: path.clone(),
185                kind,
186            });
187        }
188
189        if let Some(kind) = unstaged_kind(st) {
190            unstaged.push(EntryStatus {
191                path: path.clone(),
192                kind,
193            });
194        }
195
196        if st.intersects(Status::CONFLICTED) {
197            staged.push(EntryStatus {
198                path,
199                kind: StatusKind::Conflicted,
200            });
201        }
202    }
203
204    let clean = staged.is_empty() && unstaged.is_empty() && untracked.is_empty();
205
206    Ok(StatusOutput {
207        branch,
208        head_sha,
209        staged,
210        unstaged,
211        untracked,
212        clean,
213    })
214}
215
216fn log_sync(session: &Session, filters: LogFilters) -> Result<LogOutput> {
217    let repo = Repository::open(session.root())?;
218    let mut revwalk = repo.revwalk()?;
219    revwalk.push_head()?;
220
221    let path_specs = filters
222        .paths
223        .as_ref()
224        .and_then(|p| if p.is_empty() { None } else { Some(p.clone()) });
225
226    let mut commits = Vec::new();
227    for oid in revwalk {
228        if commits.len() >= filters.max_count {
229            break;
230        }
231        let oid = oid?;
232        let commit = repo.find_commit(oid)?;
233
234        if let Some(min_ts) = filters.since
235            && commit.time().seconds() < min_ts
236        {
237            continue;
238        }
239
240        if let Some(needle) = &filters.author {
241            let name = commit.author().name().unwrap_or("").to_string();
242            let email = commit.author().email().unwrap_or("").to_string();
243            let full = format!("{name} <{email}>");
244            if !full.contains(needle.as_str()) {
245                continue;
246            }
247        }
248
249        if let Some(needle) = &filters.grep {
250            let message = commit.message().unwrap_or("");
251            if !message.contains(needle.as_str()) {
252                continue;
253            }
254        }
255
256        if let Some(specs) = &path_specs {
257            let commit_tree = commit.tree()?;
258            let parent_tree = if commit.parent_count() > 0 {
259                Some(commit.parent(0)?.tree()?)
260            } else {
261                None
262            };
263            let mut diff_opts = DiffOptions::new();
264            for spec in specs {
265                diff_opts.pathspec(spec);
266            }
267            let diff = repo.diff_tree_to_tree(
268                parent_tree.as_ref(),
269                Some(&commit_tree),
270                Some(&mut diff_opts),
271            )?;
272            if diff.deltas().len() == 0 {
273                continue;
274            }
275        }
276
277        let sha = oid.to_string();
278        let short_sha = sha[..7.min(sha.len())].to_string();
279        let summary = commit.summary().unwrap_or("").to_string();
280        let author_name = commit.author().name().unwrap_or("").to_string();
281        let author_email = commit.author().email().unwrap_or("").to_string();
282        let author = format!("{author_name} <{author_email}>");
283        let timestamp = commit.time().seconds();
284        commits.push(CommitEntry {
285            sha,
286            short_sha,
287            summary,
288            author,
289            timestamp,
290        });
291    }
292    Ok(LogOutput { commits })
293}
294
295fn diff_sync(session: &Session, staged: bool) -> Result<DiffOutput> {
296    let repo = Repository::open(session.root())?;
297    let head_tree = match repo.head() {
298        Ok(head) => Some(head.peel_to_tree()?),
299        Err(e) if e.code() == git2::ErrorCode::UnbornBranch => None,
300        Err(e) => return Err(e.into()),
301    };
302
303    let diff = if staged {
304        repo.diff_tree_to_index(head_tree.as_ref(), None, None)?
305    } else {
306        repo.diff_index_to_workdir(None, None)?
307    };
308
309    let file_count = diff.deltas().len();
310
311    let mut patch = String::new();
312    diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
313        let origin = line.origin();
314        if matches!(origin, '+' | '-' | ' ') {
315            patch.push(origin);
316        }
317        patch.push_str(std::str::from_utf8(line.content()).unwrap_or(""));
318        true
319    })?;
320
321    Ok(DiffOutput {
322        staged,
323        patch,
324        file_count,
325    })
326}
327
328fn staged_kind(st: Status) -> Option<StatusKind> {
329    if st.intersects(Status::INDEX_NEW) {
330        Some(StatusKind::New)
331    } else if st.intersects(Status::INDEX_MODIFIED) {
332        Some(StatusKind::Modified)
333    } else if st.intersects(Status::INDEX_DELETED) {
334        Some(StatusKind::Deleted)
335    } else if st.intersects(Status::INDEX_RENAMED) {
336        Some(StatusKind::Renamed)
337    } else if st.intersects(Status::INDEX_TYPECHANGE) {
338        Some(StatusKind::Typechange)
339    } else {
340        None
341    }
342}
343
344fn unstaged_kind(st: Status) -> Option<StatusKind> {
345    if st.intersects(Status::WT_MODIFIED) {
346        Some(StatusKind::Modified)
347    } else if st.intersects(Status::WT_DELETED) {
348        Some(StatusKind::Deleted)
349    } else if st.intersects(Status::WT_RENAMED) {
350        Some(StatusKind::Renamed)
351    } else if st.intersects(Status::WT_TYPECHANGE) {
352        Some(StatusKind::Typechange)
353    } else {
354        None
355    }
356}