1use std::collections::{HashMap, HashSet};
4use std::path::Path;
5
6use git2::{
7 BranchType, Commit, Delta, DiffFormat, DiffLineType, DiffOptions, Oid, Repository, Sort,
8};
9
10use super::{
11 BlobPair, BranchInfo, ChangeStatus, CommitInfo, Diff, DiffLine, DiffLineKind, FileChange,
12 RefKind, RefLabel, RepoBackend, WorkingStatus,
13};
14
15const MAX_RENAME_PAIRS: usize = 100_000;
27
28const MAX_DIFF_LINES: usize = 50_000;
34
35pub struct Git2Backend {
37 path: String,
38 repo: Repository,
39 commits: Vec<CommitInfo>,
40}
41
42impl Git2Backend {
43 pub fn open(path: impl AsRef<str>) -> Result<Self, git2::Error> {
45 let path = path.as_ref().to_string();
46 let repo = Repository::discover(&path)?;
47 let display_path = repo
50 .workdir()
51 .map(|p| p.display().to_string())
52 .unwrap_or_else(|| repo.path().display().to_string());
53
54 let refs = collect_refs(&repo)?;
55 let commits = load_commits(&repo, &refs)?;
56
57 Ok(Self {
58 path: display_path,
59 repo,
60 commits,
61 })
62 }
63
64 fn commit_at(&self, index: usize) -> Option<Commit<'_>> {
65 let info = self.commits.get(index)?;
66 let oid = Oid::from_str(&info.id).ok()?;
67 self.repo.find_commit(oid).ok()
68 }
69
70 fn build_diff(&self, index: usize, path: Option<&str>) -> Option<git2::Diff<'_>> {
74 let commit = self.commit_at(index)?;
75 let new_tree = commit.tree().ok()?;
76 let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
77
78 let mut opts = DiffOptions::new();
79 opts.context_lines(3);
80 if let Some(path) = path {
81 opts.pathspec(path);
82 }
83
84 let mut diff = self
85 .repo
86 .diff_tree_to_tree(parent_tree.as_ref(), Some(&new_tree), Some(&mut opts))
87 .ok()?;
88 detect_renames(&mut diff);
89 Some(diff)
90 }
91
92 fn blob_in_tree(&self, tree: &git2::Tree, path: &str) -> Option<Vec<u8>> {
95 let entry = tree.get_path(Path::new(path)).ok()?;
96 let obj = entry.to_object(&self.repo).ok()?;
97 obj.as_blob().map(|b| b.content().to_vec())
98 }
99
100 fn blob_in_index(&self, path: &str) -> Option<Vec<u8>> {
102 let index = self.repo.index().ok()?;
103 let entry = index.get_path(Path::new(path), 0)?;
104 let blob = self.repo.find_blob(entry.id).ok()?;
105 Some(blob.content().to_vec())
106 }
107
108 fn blob_in_workdir(&self, path: &str) -> Option<Vec<u8>> {
110 let workdir = self.repo.workdir()?;
111 std::fs::read(workdir.join(path)).ok()
112 }
113
114 fn review_base(&self) -> Option<(String, Oid)> {
120 let remote_default = self
121 .repo
122 .find_reference("refs/remotes/origin/HEAD")
123 .ok()
124 .and_then(|r| r.symbolic_target().map(str::to_string))
125 .and_then(|t| t.strip_prefix("refs/remotes/origin/").map(str::to_string));
126 let mut candidates: Vec<&str> = Vec::new();
127 if let Some(name) = remote_default.as_deref() {
128 candidates.push(name);
129 }
130 for name in ["main", "master"] {
131 if !candidates.contains(&name) {
132 candidates.push(name);
133 }
134 }
135 for name in candidates {
136 if let Ok(branch) = self.repo.find_branch(name, BranchType::Local)
137 && let Ok(tip) = branch.get().peel_to_commit()
138 {
139 return Some((name.to_string(), tip.id()));
140 }
141 let remote_name = format!("origin/{name}");
142 if let Ok(branch) = self.repo.find_branch(&remote_name, BranchType::Remote)
143 && let Ok(tip) = branch.get().peel_to_commit()
144 {
145 return Some((remote_name, tip.id()));
146 }
147 }
148 let head = self.repo.head().ok()?;
149 let name = head.shorthand().unwrap_or("HEAD").to_string();
150 let tip = head.peel_to_commit().ok()?;
151 Some((name, tip.id()))
152 }
153
154 fn branch_info(
157 &self,
158 name: String,
159 kind: RefKind,
160 tip: &Commit,
161 upstream: Option<String>,
162 base_name: &str,
163 base_oid: Oid,
164 ) -> BranchInfo {
165 let base_id = self
166 .repo
167 .merge_base(base_oid, tip.id())
168 .ok()
169 .map(|oid| oid.to_string());
170 let author = tip.author();
171 BranchInfo {
172 name,
173 kind,
174 tip_id: tip.id().to_string(),
175 summary: tip.summary().unwrap_or("").to_string(),
176 author: author.name().unwrap_or("").to_string(),
177 time_seconds: author.when().seconds(),
178 time_offset_minutes: author.when().offset_minutes(),
179 upstream,
180 base_name: base_name.to_string(),
181 base_id,
182 }
183 }
184
185 fn branch_trees(
188 &self,
189 branch: &BranchInfo,
190 ) -> Option<(Option<git2::Tree<'_>>, git2::Tree<'_>)> {
191 let tip_oid = Oid::from_str(&branch.tip_id).ok()?;
192 let tip = self.repo.find_commit(tip_oid).ok()?.tree().ok()?;
193 let base = branch
194 .base_id
195 .as_deref()
196 .and_then(|id| Oid::from_str(id).ok())
197 .and_then(|oid| self.repo.find_commit(oid).ok())
198 .and_then(|c| c.tree().ok());
199 Some((base, tip))
200 }
201
202 fn build_branch_diff(&self, branch: &BranchInfo, path: Option<&str>) -> Option<git2::Diff<'_>> {
205 let (base, tip) = self.branch_trees(branch)?;
206 let mut opts = DiffOptions::new();
207 opts.context_lines(3);
208 if let Some(path) = path {
209 opts.pathspec(path);
210 }
211 let mut diff = self
212 .repo
213 .diff_tree_to_tree(base.as_ref(), Some(&tip), Some(&mut opts))
214 .ok()?;
215 detect_renames(&mut diff);
216 Some(diff)
217 }
218
219 fn staged_base_tree(&self, amend: bool) -> Option<git2::Tree<'_>> {
224 let head = self.repo.head().ok()?.peel_to_commit().ok()?;
225 if amend {
226 head.parent(0).ok().and_then(|p| p.tree().ok())
227 } else {
228 head.tree().ok()
229 }
230 }
231}
232
233impl RepoBackend for Git2Backend {
234 fn path(&self) -> &str {
235 &self.path
236 }
237
238 fn commits(&self) -> &[CommitInfo] {
239 &self.commits
240 }
241
242 fn changed_files(&self, index: usize) -> Vec<FileChange> {
243 let Some(diff) = self.build_diff(index, None) else {
244 return Vec::new();
245 };
246 diff.deltas()
247 .map(|delta| file_change_from_delta(&delta))
248 .collect()
249 }
250
251 fn commit_diff(&self, index: usize) -> Diff {
252 self.build_diff(index, None)
253 .map(render_diff)
254 .unwrap_or_default()
255 }
256
257 fn file_diff(&self, index: usize, path: &str) -> Diff {
258 self.build_diff(index, Some(path))
259 .map(render_diff)
260 .unwrap_or_default()
261 }
262
263 fn commit_file_blobs(&self, index: usize, path: &str) -> BlobPair {
264 let Some(commit) = self.commit_at(index) else {
265 return BlobPair::default();
266 };
267 let new = commit.tree().ok().and_then(|t| self.blob_in_tree(&t, path));
268 let old = commit
272 .parent(0)
273 .ok()
274 .and_then(|p| p.tree().ok())
275 .and_then(|t| self.blob_in_tree(&t, path));
276 BlobPair { old, new }
277 }
278
279 fn branches(&self) -> Vec<BranchInfo> {
280 let Some((base_name, base_oid)) = self.review_base() else {
281 return Vec::new();
282 };
283 let mut branches = Vec::new();
284 let mut folded: HashSet<String> = HashSet::new();
287
288 if let Ok(iter) = self.repo.branches(Some(BranchType::Local)) {
289 for (branch, _) in iter.flatten() {
290 let Ok(Some(name)) = branch.name() else {
291 continue;
292 };
293 let name = name.to_string();
294 let Ok(tip) = branch.get().peel_to_commit() else {
295 continue;
296 };
297 let kind = if branch.is_head() {
298 RefKind::Head
299 } else {
300 RefKind::LocalBranch
301 };
302 let upstream = branch.upstream().ok().and_then(|u| {
305 let uname = u.name().ok().flatten()?.to_string();
306 let utip = u.get().peel_to_commit().ok()?.id();
307 (utip == tip.id()).then_some(uname)
308 });
309 if let Some(uname) = &upstream {
310 folded.insert(uname.clone());
311 }
312 branches.push(self.branch_info(name, kind, &tip, upstream, &base_name, base_oid));
313 }
314 }
315 if let Ok(iter) = self.repo.branches(Some(BranchType::Remote)) {
316 for (branch, _) in iter.flatten() {
317 let Ok(Some(name)) = branch.name() else {
318 continue;
319 };
320 if name.ends_with("/HEAD") || folded.contains(name) {
323 continue;
324 }
325 let name = name.to_string();
326 let Ok(tip) = branch.get().peel_to_commit() else {
327 continue;
328 };
329 branches.push(self.branch_info(
330 name,
331 RefKind::RemoteBranch,
332 &tip,
333 None,
334 &base_name,
335 base_oid,
336 ));
337 }
338 }
339 branches.sort_by(|a, b| {
341 let rank = |k: RefKind| match k {
342 RefKind::Head | RefKind::DetachedHead => 0,
343 RefKind::LocalBranch => 1,
344 _ => 2,
345 };
346 rank(a.kind).cmp(&rank(b.kind)).then(a.name.cmp(&b.name))
347 });
348 branches
349 }
350
351 fn branch_files(&self, branch: &BranchInfo) -> Vec<FileChange> {
352 let Some(diff) = self.build_branch_diff(branch, None) else {
353 return Vec::new();
354 };
355 diff.deltas()
356 .map(|delta| file_change_from_delta(&delta))
357 .collect()
358 }
359
360 fn branch_diff(&self, branch: &BranchInfo) -> Diff {
361 self.build_branch_diff(branch, None)
362 .map(render_diff)
363 .unwrap_or_default()
364 }
365
366 fn branch_file_diff(&self, branch: &BranchInfo, path: &str) -> Diff {
367 self.build_branch_diff(branch, Some(path))
368 .map(render_diff)
369 .unwrap_or_default()
370 }
371
372 fn branch_file_blobs(&self, branch: &BranchInfo, path: &str) -> BlobPair {
373 let Some((base, tip)) = self.branch_trees(branch) else {
374 return BlobPair::default();
375 };
376 BlobPair {
377 old: base.as_ref().and_then(|t| self.blob_in_tree(t, path)),
378 new: self.blob_in_tree(&tip, path),
379 }
380 }
381
382 fn working_file_blobs(&self, path: &str, staged: bool, amend: bool) -> BlobPair {
383 if staged {
384 let old = self
386 .staged_base_tree(amend)
387 .and_then(|t| self.blob_in_tree(&t, path));
388 BlobPair {
389 old,
390 new: self.blob_in_index(path),
391 }
392 } else {
393 BlobPair {
395 old: self.blob_in_index(path),
396 new: self.blob_in_workdir(path),
397 }
398 }
399 }
400
401 fn working_status(&self, amend: bool) -> WorkingStatus {
402 let base = self.staged_base_tree(amend);
403
404 let mut staged_opts = DiffOptions::new();
408 let mut staged = WorkingStatus::default();
409 if let Ok(mut diff) =
410 self.repo
411 .diff_tree_to_index(base.as_ref(), None, Some(&mut staged_opts))
412 {
413 let _ = diff.find_similar(None);
414 for delta in diff.deltas() {
415 staged.staged.push(file_change_from_delta(&delta));
416 }
417 }
418
419 let mut wd_opts = DiffOptions::new();
422 wd_opts.include_untracked(true).recurse_untracked_dirs(true);
423 if let Ok(diff) = self.repo.diff_index_to_workdir(None, Some(&mut wd_opts)) {
424 for delta in diff.deltas() {
425 staged.unstaged.push(file_change_from_delta(&delta));
426 }
427 }
428
429 staged
430 }
431
432 fn working_diff(&self, path: &str, staged: bool, amend: bool) -> Diff {
433 let mut opts = DiffOptions::new();
434 opts.context_lines(3).pathspec(path);
435 let diff = if staged {
436 let base = self.staged_base_tree(amend);
437 self.repo
438 .diff_tree_to_index(base.as_ref(), None, Some(&mut opts))
439 } else {
440 opts.include_untracked(true)
441 .recurse_untracked_dirs(true)
442 .show_untracked_content(true);
443 self.repo.diff_index_to_workdir(None, Some(&mut opts))
444 };
445 diff.ok().map(render_diff).unwrap_or_default()
446 }
447
448 fn stage(&self, path: &str) -> Result<(), String> {
449 let mut index = self.repo.index().map_err(err_msg)?;
450 let p = Path::new(path);
451 let in_workdir = self
452 .repo
453 .workdir()
454 .map(|w| w.join(path).exists())
455 .unwrap_or(false);
456 if in_workdir {
457 index.add_path(p).map_err(err_msg)?;
458 } else {
459 index.remove_path(p).map_err(err_msg)?;
461 }
462 index.write().map_err(err_msg)
463 }
464
465 fn unstage(&self, path: &str, amend: bool) -> Result<(), String> {
466 let head = self.repo.head().ok().and_then(|h| h.peel_to_commit().ok());
469 let target: Option<git2::Object> = match (amend, head) {
470 (false, Some(commit)) => Some(commit.into_object()),
471 (true, Some(commit)) => commit.parent(0).ok().map(|p| p.into_object()),
472 (_, None) => None,
473 };
474 match target {
475 Some(obj) => self.repo.reset_default(Some(&obj), [path]).map_err(err_msg),
476 None => {
479 let mut index = self.repo.index().map_err(err_msg)?;
480 index.remove_path(Path::new(path)).map_err(err_msg)?;
481 index.write().map_err(err_msg)
482 }
483 }
484 }
485
486 fn revert(&self, path: &str) -> Result<(), String> {
487 let mut opts = git2::build::CheckoutBuilder::new();
493 opts.force().update_index(false).path(path);
494 self.repo
495 .checkout_index(None, Some(&mut opts))
496 .map_err(err_msg)
497 }
498
499 fn delete_untracked(&self, path: &str) -> Result<(), String> {
500 let workdir = self
501 .repo
502 .workdir()
503 .ok_or_else(|| "bare repository has no working tree".to_string())?;
504 std::fs::remove_file(workdir.join(path)).map_err(|e| e.to_string())
505 }
506
507 fn apply_to_index(&self, patch: &str) -> Result<(), String> {
508 let diff = git2::Diff::from_buffer(patch.as_bytes()).map_err(err_msg)?;
509 self.repo
510 .apply(&diff, git2::ApplyLocation::Index, None)
511 .map_err(err_msg)
512 }
513
514 fn commit(&self, message: &str, amend: bool) -> Result<(), String> {
515 if message.trim().is_empty() {
516 return Err("Please enter a commit message.".into());
517 }
518 let mut index = self.repo.index().map_err(err_msg)?;
519 let tree_oid = index.write_tree().map_err(err_msg)?;
520 let tree = self.repo.find_tree(tree_oid).map_err(err_msg)?;
521
522 if amend {
523 let head = self
524 .repo
525 .head()
526 .and_then(|h| h.peel_to_commit())
527 .map_err(err_msg)?;
528 head.amend(Some("HEAD"), None, None, None, Some(message), Some(&tree))
531 .map_err(err_msg)?;
532 } else {
533 let sig = self.repo.signature().map_err(|_| {
534 "No git identity configured. Set user.name and user.email.".to_string()
535 })?;
536 let parent = self.repo.head().ok().and_then(|h| h.peel_to_commit().ok());
537 let parents: Vec<&Commit> = parent.iter().collect();
538 self.repo
539 .commit(Some("HEAD"), &sig, &sig, message, &tree, &parents)
540 .map_err(err_msg)?;
541 }
542 Ok(())
543 }
544
545 fn head_message(&self) -> Option<String> {
546 let commit = self.repo.head().ok()?.peel_to_commit().ok()?;
547 Some(commit.message().unwrap_or("").to_string())
548 }
549
550 fn signature(&self) -> Option<(String, String)> {
551 let sig = self.repo.signature().ok()?;
552 Some((sig.name()?.to_string(), sig.email()?.to_string()))
553 }
554}
555
556fn file_change_from_delta(delta: &git2::DiffDelta) -> FileChange {
559 let new_path = delta.new_file().path().map(|p| p.display().to_string());
560 let old_path = delta.old_file().path().map(|p| p.display().to_string());
561 let status = status_from_delta(delta.status());
562 let path = new_path
563 .clone()
564 .or_else(|| old_path.clone())
565 .unwrap_or_default();
566 FileChange {
567 path,
568 old_path: old_path.filter(|o| Some(o) != new_path.as_ref()),
569 status,
570 }
571}
572
573fn err_msg(e: git2::Error) -> String {
575 e.message().to_string()
576}
577
578fn collect_refs(repo: &Repository) -> Result<HashMap<Oid, Vec<RefLabel>>, git2::Error> {
582 let mut map: HashMap<Oid, Vec<RefLabel>> = HashMap::new();
583
584 let head = repo.head().ok();
585 let head_branch = head
586 .as_ref()
587 .filter(|h| h.is_branch())
588 .and_then(|h| h.shorthand())
589 .map(str::to_string);
590 let detached = repo.head_detached().unwrap_or(false);
591
592 if detached && let Some(oid) = head.as_ref().and_then(|h| h.target()) {
593 map.entry(oid).or_default().push(RefLabel {
594 name: "HEAD".into(),
595 kind: RefKind::DetachedHead,
596 });
597 }
598
599 if let Ok(references) = repo.references() {
600 for reference in references.flatten() {
601 let Ok(commit) = reference.peel_to_commit() else {
602 continue;
603 };
604 let oid = commit.id();
605 let Some(name) = reference.shorthand().map(str::to_string) else {
606 continue;
607 };
608 let kind = if reference.is_tag() {
609 RefKind::Tag
610 } else if reference.is_remote() {
611 if name.ends_with("/HEAD") {
613 continue;
614 }
615 RefKind::RemoteBranch
616 } else if reference.is_branch() {
617 if head_branch.as_deref() == Some(name.as_str()) {
618 RefKind::Head
619 } else {
620 RefKind::LocalBranch
621 }
622 } else {
623 continue;
624 };
625 map.entry(oid).or_default().push(RefLabel { name, kind });
626 }
627 }
628
629 for labels in map.values_mut() {
631 labels.sort_by_key(|l| match l.kind {
632 RefKind::Head | RefKind::DetachedHead => 0,
633 RefKind::LocalBranch => 1,
634 RefKind::RemoteBranch => 2,
635 RefKind::Tag => 3,
636 });
637 }
638
639 Ok(map)
640}
641
642fn load_commits(
645 repo: &Repository,
646 refs: &HashMap<Oid, Vec<RefLabel>>,
647) -> Result<Vec<CommitInfo>, git2::Error> {
648 let mut revwalk = repo.revwalk()?;
649 revwalk.set_sorting(Sort::TIME | Sort::TOPOLOGICAL)?;
650 let _ = revwalk.push_head();
653
654 let mut commits = Vec::new();
655 for oid in revwalk {
656 let oid = oid?;
657 let commit = repo.find_commit(oid)?;
658 commits.push(commit_info(&commit, refs));
659 }
660 Ok(commits)
661}
662
663fn commit_info(commit: &Commit, refs: &HashMap<Oid, Vec<RefLabel>>) -> CommitInfo {
664 let id = commit.id().to_string();
665 let short_id = id.chars().take(8).collect();
666 let message = commit.message().unwrap_or("").to_string();
667 let summary = commit
668 .summary()
669 .map(str::to_string)
670 .unwrap_or_else(|| message.lines().next().unwrap_or("").to_string());
671 let author = commit.author();
672 let committer = commit.committer();
673 let time = author.when();
674
675 CommitInfo {
676 short_id,
677 summary,
678 message,
679 author_name: author.name().unwrap_or("").to_string(),
680 author_email: author.email().unwrap_or("").to_string(),
681 committer_name: committer.name().unwrap_or("").to_string(),
682 committer_email: committer.email().unwrap_or("").to_string(),
683 time_seconds: time.seconds(),
684 time_offset_minutes: time.offset_minutes(),
685 parents: commit.parent_ids().map(|p| p.to_string()).collect(),
686 refs: refs.get(&commit.id()).cloned().unwrap_or_default(),
687 id,
688 }
689}
690
691fn status_from_delta(delta: Delta) -> ChangeStatus {
692 match delta {
693 Delta::Added => ChangeStatus::Added,
694 Delta::Deleted => ChangeStatus::Deleted,
695 Delta::Modified => ChangeStatus::Modified,
696 Delta::Renamed => ChangeStatus::Renamed,
697 Delta::Copied => ChangeStatus::Copied,
698 Delta::Typechange => ChangeStatus::TypeChange,
699 Delta::Untracked => ChangeStatus::Untracked,
700 _ => ChangeStatus::Other,
701 }
702}
703
704fn detect_renames(diff: &mut git2::Diff) {
711 let (mut added, mut deleted) = (0usize, 0usize);
712 for delta in diff.deltas() {
713 match delta.status() {
714 Delta::Added => added += 1,
715 Delta::Deleted => deleted += 1,
716 _ => {}
717 }
718 }
719 if added.saturating_mul(deleted) <= MAX_RENAME_PAIRS {
720 let _ = diff.find_similar(None);
721 }
722}
723
724fn render_diff(diff: git2::Diff) -> Diff {
729 let mut lines = Vec::new();
730 let mut truncated = false;
731 let _ = diff.print(DiffFormat::Patch, |_delta, _hunk, line| {
732 if lines.len() >= MAX_DIFF_LINES {
735 truncated = true;
736 return false;
737 }
738 let content = String::from_utf8_lossy(line.content());
739 let content = content.trim_end_matches('\n');
740 match line.origin_value() {
741 DiffLineType::FileHeader => {
742 push_multiline(&mut lines, DiffLineKind::FileHeader, content)
743 }
744 DiffLineType::HunkHeader => {
745 push_multiline(&mut lines, DiffLineKind::HunkHeader, content)
746 }
747 DiffLineType::Context => {
748 lines.push(DiffLine::new(DiffLineKind::Context, format!(" {content}")))
749 }
750 DiffLineType::Addition => {
751 lines.push(DiffLine::new(DiffLineKind::Addition, format!("+{content}")))
752 }
753 DiffLineType::Deletion => {
754 lines.push(DiffLine::new(DiffLineKind::Deletion, format!("-{content}")))
755 }
756 DiffLineType::ContextEOFNL | DiffLineType::AddEOFNL | DiffLineType::DeleteEOFNL => {
757 lines.push(DiffLine::new(DiffLineKind::Meta, content.to_string()))
758 }
759 _ => push_multiline(&mut lines, DiffLineKind::Meta, content),
760 }
761 true
762 });
763 if truncated {
764 lines.push(DiffLine::new(
765 DiffLineKind::Meta,
766 format!(
767 "\u{2026} diff truncated at {MAX_DIFF_LINES} lines — too large to display in full"
768 ),
769 ));
770 }
771 Diff { lines }
772}
773
774fn push_multiline(out: &mut Vec<DiffLine>, kind: DiffLineKind, content: &str) {
775 for line in content.split('\n') {
776 out.push(DiffLine::new(kind, line.to_string()));
777 }
778}