1use 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 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 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 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 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}