1use 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#[derive(Debug, Clone, Default)]
30pub struct LogFilters {
31 pub max_count: usize,
33 pub author: Option<String>,
35 pub paths: Option<Vec<String>>,
39 pub since: Option<i64>,
42 pub grep: Option<String>,
45}
46
47impl GitModule {
48 pub async fn status(&self) -> Result<StatusOutput> {
53 let session = Arc::clone(&self.session);
54 blocking(move || status_sync(&session)).await
55 }
56
57 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 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 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
135pub(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}