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