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::{Repository, Status, StatusOptions};
10
11use crate::output::{
12    CommitEntry, DiffOutput, EntryStatus, LogOutput, StatusKind, StatusOutput, WorktreeEntry,
13    WorktreeListOutput,
14};
15use crate::{GitModule, git_cmd};
16
17impl GitModule {
18    /// Inspect the working tree and produce a [`StatusOutput`] split into
19    /// staged / unstaged / untracked buckets.
20    pub fn status(&self) -> Result<StatusOutput> {
21        let repo = Repository::open(self.session().root())?;
22
23        let head_sha = match repo.head() {
24            Ok(head) => head.target().map(|oid| oid.to_string()),
25            Err(e) if e.code() == git2::ErrorCode::UnbornBranch => None,
26            Err(e) => return Err(e.into()),
27        };
28
29        let branch = match repo.head() {
30            Ok(head) => head.shorthand().map(|s| s.to_string()),
31            Err(e) if e.code() == git2::ErrorCode::UnbornBranch => None,
32            Err(e) => return Err(e.into()),
33        };
34
35        let mut opts = StatusOptions::new();
36        opts.include_untracked(true).recurse_untracked_dirs(true);
37        let statuses = repo.statuses(Some(&mut opts))?;
38
39        let mut staged = Vec::new();
40        let mut unstaged = Vec::new();
41        let mut untracked = Vec::new();
42
43        for entry in statuses.iter() {
44            let path = match entry.path() {
45                Some(p) => PathBuf::from(p),
46                None => continue,
47            };
48            let st = entry.status();
49
50            if st.intersects(Status::WT_NEW) {
51                untracked.push(path.clone());
52            }
53
54            if let Some(kind) = staged_kind(st) {
55                staged.push(EntryStatus {
56                    path: path.clone(),
57                    kind,
58                });
59            }
60
61            if let Some(kind) = unstaged_kind(st) {
62                unstaged.push(EntryStatus {
63                    path: path.clone(),
64                    kind,
65                });
66            }
67
68            if st.intersects(Status::CONFLICTED) {
69                staged.push(EntryStatus {
70                    path,
71                    kind: StatusKind::Conflicted,
72                });
73            }
74        }
75
76        let clean = staged.is_empty() && unstaged.is_empty() && untracked.is_empty();
77
78        Ok(StatusOutput {
79            branch,
80            head_sha,
81            staged,
82            unstaged,
83            untracked,
84            clean,
85        })
86    }
87
88    /// Walk HEAD back up to `max_count` commits.
89    pub fn log(&self, max_count: usize) -> Result<LogOutput> {
90        let repo = Repository::open(self.session().root())?;
91        let mut revwalk = repo.revwalk()?;
92        revwalk.push_head()?;
93        let mut commits = Vec::new();
94        for (i, oid) in revwalk.enumerate() {
95            if i >= max_count {
96                break;
97            }
98            let oid = oid?;
99            let commit = repo.find_commit(oid)?;
100            let sha = oid.to_string();
101            let short_sha = sha[..7.min(sha.len())].to_string();
102            let summary = commit.summary().unwrap_or("").to_string();
103            commits.push(CommitEntry {
104                sha,
105                short_sha,
106                summary,
107            });
108        }
109        Ok(LogOutput { commits })
110    }
111
112    /// Produce the patch for either the staged (HEAD vs index) or the
113    /// unstaged (index vs worktree) view, controlled by `staged`.
114    pub fn diff(&self, staged: bool) -> Result<DiffOutput> {
115        let repo = Repository::open(self.session().root())?;
116        let head_tree = match repo.head() {
117            Ok(head) => Some(head.peel_to_tree()?),
118            Err(e) if e.code() == git2::ErrorCode::UnbornBranch => None,
119            Err(e) => return Err(e.into()),
120        };
121
122        let diff = if staged {
123            repo.diff_tree_to_index(head_tree.as_ref(), None, None)?
124        } else {
125            repo.diff_index_to_workdir(None, None)?
126        };
127
128        let file_count = diff.deltas().len();
129
130        let mut patch = String::new();
131        diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
132            let origin = line.origin();
133            if matches!(origin, '+' | '-' | ' ') {
134                patch.push(origin);
135            }
136            patch.push_str(std::str::from_utf8(line.content()).unwrap_or(""));
137            true
138        })?;
139
140        Ok(DiffOutput {
141            staged,
142            patch,
143            file_count,
144        })
145    }
146
147    /// List every linked worktree, annotated with session-ownership.
148    pub fn worktree_list(&self) -> Result<WorktreeListOutput> {
149        let raw = git_cmd(self.session().root(), &["worktree", "list", "--porcelain"])?;
150
151        let mut worktrees = Vec::new();
152        let mut current_path: Option<PathBuf> = None;
153        let mut current_head: Option<String> = None;
154        let mut current_branch: Option<String> = None;
155
156        for line in raw.lines() {
157            if line.is_empty() {
158                if let Some(p) = current_path.take() {
159                    worktrees.push(self.make_worktree_entry(
160                        p,
161                        current_head.take(),
162                        current_branch.take(),
163                    ));
164                }
165                continue;
166            }
167            if let Some(p) = line.strip_prefix("worktree ") {
168                current_path = Some(PathBuf::from(p));
169            } else if let Some(h) = line.strip_prefix("HEAD ") {
170                current_head = Some(h.to_string());
171            } else if let Some(b) = line.strip_prefix("branch ") {
172                current_branch = Some(b.trim_start_matches("refs/heads/").to_string());
173            }
174        }
175        if let Some(p) = current_path {
176            worktrees.push(self.make_worktree_entry(p, current_head, current_branch));
177        }
178
179        Ok(WorktreeListOutput { worktrees })
180    }
181
182    fn make_worktree_entry(
183        &self,
184        path: PathBuf,
185        head: Option<String>,
186        branch: Option<String>,
187    ) -> WorktreeEntry {
188        let owned = self.owned_worktrees.contains(&path)
189            || path
190                .canonicalize()
191                .map(|c| self.owned_worktrees.contains(&c))
192                .unwrap_or(false);
193        WorktreeEntry {
194            path,
195            head,
196            branch,
197            owned,
198        }
199    }
200}
201
202fn staged_kind(st: Status) -> Option<StatusKind> {
203    if st.intersects(Status::INDEX_NEW) {
204        Some(StatusKind::New)
205    } else if st.intersects(Status::INDEX_MODIFIED) {
206        Some(StatusKind::Modified)
207    } else if st.intersects(Status::INDEX_DELETED) {
208        Some(StatusKind::Deleted)
209    } else if st.intersects(Status::INDEX_RENAMED) {
210        Some(StatusKind::Renamed)
211    } else if st.intersects(Status::INDEX_TYPECHANGE) {
212        Some(StatusKind::Typechange)
213    } else {
214        None
215    }
216}
217
218fn unstaged_kind(st: Status) -> Option<StatusKind> {
219    if st.intersects(Status::WT_MODIFIED) {
220        Some(StatusKind::Modified)
221    } else if st.intersects(Status::WT_DELETED) {
222        Some(StatusKind::Deleted)
223    } else if st.intersects(Status::WT_RENAMED) {
224        Some(StatusKind::Renamed)
225    } else if st.intersects(Status::WT_TYPECHANGE) {
226        Some(StatusKind::Typechange)
227    } else {
228        None
229    }
230}