1use std::collections::HashMap;
4use std::path::Path;
5
6use git2::{Commit, Delta, DiffFormat, DiffLineType, DiffOptions, Oid, Repository, Sort};
7
8use super::{
9 ChangeStatus, CommitInfo, Diff, DiffLine, DiffLineKind, FileChange, RefKind, RefLabel,
10 RepoBackend, WorkingStatus,
11};
12
13pub struct Git2Backend {
15 path: String,
16 repo: Repository,
17 commits: Vec<CommitInfo>,
18}
19
20impl Git2Backend {
21 pub fn open(path: impl AsRef<str>) -> Result<Self, git2::Error> {
23 let path = path.as_ref().to_string();
24 let repo = Repository::discover(&path)?;
25 let display_path = repo
28 .workdir()
29 .map(|p| p.display().to_string())
30 .unwrap_or_else(|| repo.path().display().to_string());
31
32 let refs = collect_refs(&repo)?;
33 let commits = load_commits(&repo, &refs)?;
34
35 Ok(Self {
36 path: display_path,
37 repo,
38 commits,
39 })
40 }
41
42 fn commit_at(&self, index: usize) -> Option<Commit<'_>> {
43 let info = self.commits.get(index)?;
44 let oid = Oid::from_str(&info.id).ok()?;
45 self.repo.find_commit(oid).ok()
46 }
47
48 fn build_diff(&self, index: usize, path: Option<&str>) -> Option<git2::Diff<'_>> {
52 let commit = self.commit_at(index)?;
53 let new_tree = commit.tree().ok()?;
54 let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
55
56 let mut opts = DiffOptions::new();
57 opts.context_lines(3);
58 if let Some(path) = path {
59 opts.pathspec(path);
60 }
61
62 let mut diff = self
63 .repo
64 .diff_tree_to_tree(parent_tree.as_ref(), Some(&new_tree), Some(&mut opts))
65 .ok()?;
66 let _ = diff.find_similar(None);
68 Some(diff)
69 }
70
71 fn staged_base_tree(&self, amend: bool) -> Option<git2::Tree<'_>> {
76 let head = self.repo.head().ok()?.peel_to_commit().ok()?;
77 if amend {
78 head.parent(0).ok().and_then(|p| p.tree().ok())
79 } else {
80 head.tree().ok()
81 }
82 }
83}
84
85impl RepoBackend for Git2Backend {
86 fn path(&self) -> &str {
87 &self.path
88 }
89
90 fn commits(&self) -> &[CommitInfo] {
91 &self.commits
92 }
93
94 fn changed_files(&self, index: usize) -> Vec<FileChange> {
95 let Some(diff) = self.build_diff(index, None) else {
96 return Vec::new();
97 };
98 diff.deltas()
99 .map(|delta| file_change_from_delta(&delta))
100 .collect()
101 }
102
103 fn commit_diff(&self, index: usize) -> Diff {
104 self.build_diff(index, None)
105 .map(render_diff)
106 .unwrap_or_default()
107 }
108
109 fn file_diff(&self, index: usize, path: &str) -> Diff {
110 self.build_diff(index, Some(path))
111 .map(render_diff)
112 .unwrap_or_default()
113 }
114
115 fn working_status(&self, amend: bool) -> WorkingStatus {
116 let base = self.staged_base_tree(amend);
117
118 let mut staged_opts = DiffOptions::new();
122 let mut staged = WorkingStatus::default();
123 if let Ok(mut diff) =
124 self.repo
125 .diff_tree_to_index(base.as_ref(), None, Some(&mut staged_opts))
126 {
127 let _ = diff.find_similar(None);
128 for delta in diff.deltas() {
129 staged.staged.push(file_change_from_delta(&delta));
130 }
131 }
132
133 let mut wd_opts = DiffOptions::new();
136 wd_opts.include_untracked(true).recurse_untracked_dirs(true);
137 if let Ok(diff) = self.repo.diff_index_to_workdir(None, Some(&mut wd_opts)) {
138 for delta in diff.deltas() {
139 staged.unstaged.push(file_change_from_delta(&delta));
140 }
141 }
142
143 staged
144 }
145
146 fn working_diff(&self, path: &str, staged: bool, amend: bool) -> Diff {
147 let mut opts = DiffOptions::new();
148 opts.context_lines(3).pathspec(path);
149 let diff = if staged {
150 let base = self.staged_base_tree(amend);
151 self.repo
152 .diff_tree_to_index(base.as_ref(), None, Some(&mut opts))
153 } else {
154 opts.include_untracked(true)
155 .recurse_untracked_dirs(true)
156 .show_untracked_content(true);
157 self.repo.diff_index_to_workdir(None, Some(&mut opts))
158 };
159 diff.ok().map(render_diff).unwrap_or_default()
160 }
161
162 fn stage(&self, path: &str) -> Result<(), String> {
163 let mut index = self.repo.index().map_err(err_msg)?;
164 let p = Path::new(path);
165 let in_workdir = self
166 .repo
167 .workdir()
168 .map(|w| w.join(path).exists())
169 .unwrap_or(false);
170 if in_workdir {
171 index.add_path(p).map_err(err_msg)?;
172 } else {
173 index.remove_path(p).map_err(err_msg)?;
175 }
176 index.write().map_err(err_msg)
177 }
178
179 fn unstage(&self, path: &str, amend: bool) -> Result<(), String> {
180 let head = self.repo.head().ok().and_then(|h| h.peel_to_commit().ok());
183 let target: Option<git2::Object> = match (amend, head) {
184 (false, Some(commit)) => Some(commit.into_object()),
185 (true, Some(commit)) => commit.parent(0).ok().map(|p| p.into_object()),
186 (_, None) => None,
187 };
188 match target {
189 Some(obj) => self.repo.reset_default(Some(&obj), [path]).map_err(err_msg),
190 None => {
193 let mut index = self.repo.index().map_err(err_msg)?;
194 index.remove_path(Path::new(path)).map_err(err_msg)?;
195 index.write().map_err(err_msg)
196 }
197 }
198 }
199
200 fn revert(&self, path: &str) -> Result<(), String> {
201 let mut opts = git2::build::CheckoutBuilder::new();
207 opts.force().update_index(false).path(path);
208 self.repo
209 .checkout_index(None, Some(&mut opts))
210 .map_err(err_msg)
211 }
212
213 fn delete_untracked(&self, path: &str) -> Result<(), String> {
214 let workdir = self
215 .repo
216 .workdir()
217 .ok_or_else(|| "bare repository has no working tree".to_string())?;
218 std::fs::remove_file(workdir.join(path)).map_err(|e| e.to_string())
219 }
220
221 fn commit(&self, message: &str, amend: bool) -> Result<(), String> {
222 if message.trim().is_empty() {
223 return Err("Please enter a commit message.".into());
224 }
225 let mut index = self.repo.index().map_err(err_msg)?;
226 let tree_oid = index.write_tree().map_err(err_msg)?;
227 let tree = self.repo.find_tree(tree_oid).map_err(err_msg)?;
228
229 if amend {
230 let head = self
231 .repo
232 .head()
233 .and_then(|h| h.peel_to_commit())
234 .map_err(err_msg)?;
235 head.amend(Some("HEAD"), None, None, None, Some(message), Some(&tree))
238 .map_err(err_msg)?;
239 } else {
240 let sig = self.repo.signature().map_err(|_| {
241 "No git identity configured. Set user.name and user.email.".to_string()
242 })?;
243 let parent = self.repo.head().ok().and_then(|h| h.peel_to_commit().ok());
244 let parents: Vec<&Commit> = parent.iter().collect();
245 self.repo
246 .commit(Some("HEAD"), &sig, &sig, message, &tree, &parents)
247 .map_err(err_msg)?;
248 }
249 Ok(())
250 }
251
252 fn head_message(&self) -> Option<String> {
253 let commit = self.repo.head().ok()?.peel_to_commit().ok()?;
254 Some(commit.message().unwrap_or("").to_string())
255 }
256
257 fn signature(&self) -> Option<(String, String)> {
258 let sig = self.repo.signature().ok()?;
259 Some((sig.name()?.to_string(), sig.email()?.to_string()))
260 }
261}
262
263fn file_change_from_delta(delta: &git2::DiffDelta) -> FileChange {
266 let new_path = delta.new_file().path().map(|p| p.display().to_string());
267 let old_path = delta.old_file().path().map(|p| p.display().to_string());
268 let status = status_from_delta(delta.status());
269 let path = new_path
270 .clone()
271 .or_else(|| old_path.clone())
272 .unwrap_or_default();
273 FileChange {
274 path,
275 old_path: old_path.filter(|o| Some(o) != new_path.as_ref()),
276 status,
277 }
278}
279
280fn err_msg(e: git2::Error) -> String {
282 e.message().to_string()
283}
284
285fn collect_refs(repo: &Repository) -> Result<HashMap<Oid, Vec<RefLabel>>, git2::Error> {
289 let mut map: HashMap<Oid, Vec<RefLabel>> = HashMap::new();
290
291 let head = repo.head().ok();
292 let head_branch = head
293 .as_ref()
294 .filter(|h| h.is_branch())
295 .and_then(|h| h.shorthand())
296 .map(str::to_string);
297 let detached = repo.head_detached().unwrap_or(false);
298
299 if detached && let Some(oid) = head.as_ref().and_then(|h| h.target()) {
300 map.entry(oid).or_default().push(RefLabel {
301 name: "HEAD".into(),
302 kind: RefKind::DetachedHead,
303 });
304 }
305
306 if let Ok(references) = repo.references() {
307 for reference in references.flatten() {
308 let Ok(commit) = reference.peel_to_commit() else {
309 continue;
310 };
311 let oid = commit.id();
312 let Some(name) = reference.shorthand().map(str::to_string) else {
313 continue;
314 };
315 let kind = if reference.is_tag() {
316 RefKind::Tag
317 } else if reference.is_remote() {
318 if name.ends_with("/HEAD") {
320 continue;
321 }
322 RefKind::RemoteBranch
323 } else if reference.is_branch() {
324 if head_branch.as_deref() == Some(name.as_str()) {
325 RefKind::Head
326 } else {
327 RefKind::LocalBranch
328 }
329 } else {
330 continue;
331 };
332 map.entry(oid).or_default().push(RefLabel { name, kind });
333 }
334 }
335
336 for labels in map.values_mut() {
338 labels.sort_by_key(|l| match l.kind {
339 RefKind::Head | RefKind::DetachedHead => 0,
340 RefKind::LocalBranch => 1,
341 RefKind::RemoteBranch => 2,
342 RefKind::Tag => 3,
343 });
344 }
345
346 Ok(map)
347}
348
349fn load_commits(
352 repo: &Repository,
353 refs: &HashMap<Oid, Vec<RefLabel>>,
354) -> Result<Vec<CommitInfo>, git2::Error> {
355 let mut revwalk = repo.revwalk()?;
356 revwalk.set_sorting(Sort::TIME | Sort::TOPOLOGICAL)?;
357 if revwalk.push_glob("refs/heads/*").is_err() {
360 let _ = revwalk.push_head();
361 }
362 let _ = revwalk.push_glob("refs/remotes/*");
363 let _ = revwalk.push_glob("refs/tags/*");
364 let _ = revwalk.push_head();
365
366 let mut commits = Vec::new();
367 for oid in revwalk {
368 let oid = oid?;
369 let commit = repo.find_commit(oid)?;
370 commits.push(commit_info(&commit, refs));
371 }
372 Ok(commits)
373}
374
375fn commit_info(commit: &Commit, refs: &HashMap<Oid, Vec<RefLabel>>) -> CommitInfo {
376 let id = commit.id().to_string();
377 let short_id = id.chars().take(8).collect();
378 let message = commit.message().unwrap_or("").to_string();
379 let summary = commit
380 .summary()
381 .map(str::to_string)
382 .unwrap_or_else(|| message.lines().next().unwrap_or("").to_string());
383 let author = commit.author();
384 let committer = commit.committer();
385 let time = author.when();
386
387 CommitInfo {
388 short_id,
389 summary,
390 message,
391 author_name: author.name().unwrap_or("").to_string(),
392 author_email: author.email().unwrap_or("").to_string(),
393 committer_name: committer.name().unwrap_or("").to_string(),
394 committer_email: committer.email().unwrap_or("").to_string(),
395 time_seconds: time.seconds(),
396 time_offset_minutes: time.offset_minutes(),
397 parents: commit.parent_ids().map(|p| p.to_string()).collect(),
398 refs: refs.get(&commit.id()).cloned().unwrap_or_default(),
399 id,
400 }
401}
402
403fn status_from_delta(delta: Delta) -> ChangeStatus {
404 match delta {
405 Delta::Added => ChangeStatus::Added,
406 Delta::Deleted => ChangeStatus::Deleted,
407 Delta::Modified => ChangeStatus::Modified,
408 Delta::Renamed => ChangeStatus::Renamed,
409 Delta::Copied => ChangeStatus::Copied,
410 Delta::Typechange => ChangeStatus::TypeChange,
411 Delta::Untracked => ChangeStatus::Untracked,
412 _ => ChangeStatus::Other,
413 }
414}
415
416fn render_diff(diff: git2::Diff) -> Diff {
421 let mut lines = Vec::new();
422 let _ = diff.print(DiffFormat::Patch, |_delta, _hunk, line| {
423 let content = String::from_utf8_lossy(line.content());
424 let content = content.trim_end_matches('\n');
425 match line.origin_value() {
426 DiffLineType::FileHeader => {
427 push_multiline(&mut lines, DiffLineKind::FileHeader, content)
428 }
429 DiffLineType::HunkHeader => {
430 push_multiline(&mut lines, DiffLineKind::HunkHeader, content)
431 }
432 DiffLineType::Context => {
433 lines.push(DiffLine::new(DiffLineKind::Context, format!(" {content}")))
434 }
435 DiffLineType::Addition => {
436 lines.push(DiffLine::new(DiffLineKind::Addition, format!("+{content}")))
437 }
438 DiffLineType::Deletion => {
439 lines.push(DiffLine::new(DiffLineKind::Deletion, format!("-{content}")))
440 }
441 DiffLineType::ContextEOFNL | DiffLineType::AddEOFNL | DiffLineType::DeleteEOFNL => {
442 lines.push(DiffLine::new(DiffLineKind::Meta, content.to_string()))
443 }
444 _ => push_multiline(&mut lines, DiffLineKind::Meta, content),
445 }
446 true
447 });
448 Diff { lines }
449}
450
451fn push_multiline(out: &mut Vec<DiffLine>, kind: DiffLineKind, content: &str) {
452 for line in content.split('\n') {
453 out.push(DiffLine::new(kind, line.to_string()));
454 }
455}