1use 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#[derive(Debug, Clone, Default)]
20pub struct LogFilters {
21 pub max_count: usize,
23 pub author: Option<String>,
25 pub paths: Option<Vec<String>>,
29 pub since: Option<i64>,
32 pub grep: Option<String>,
35}
36
37impl GitModule {
38 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 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 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 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}