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 apply_to_index(&self, patch: &str) -> Result<(), String> {
222 let diff = git2::Diff::from_buffer(patch.as_bytes()).map_err(err_msg)?;
223 self.repo
224 .apply(&diff, git2::ApplyLocation::Index, None)
225 .map_err(err_msg)
226 }
227
228 fn commit(&self, message: &str, amend: bool) -> Result<(), String> {
229 if message.trim().is_empty() {
230 return Err("Please enter a commit message.".into());
231 }
232 let mut index = self.repo.index().map_err(err_msg)?;
233 let tree_oid = index.write_tree().map_err(err_msg)?;
234 let tree = self.repo.find_tree(tree_oid).map_err(err_msg)?;
235
236 if amend {
237 let head = self
238 .repo
239 .head()
240 .and_then(|h| h.peel_to_commit())
241 .map_err(err_msg)?;
242 head.amend(Some("HEAD"), None, None, None, Some(message), Some(&tree))
245 .map_err(err_msg)?;
246 } else {
247 let sig = self.repo.signature().map_err(|_| {
248 "No git identity configured. Set user.name and user.email.".to_string()
249 })?;
250 let parent = self.repo.head().ok().and_then(|h| h.peel_to_commit().ok());
251 let parents: Vec<&Commit> = parent.iter().collect();
252 self.repo
253 .commit(Some("HEAD"), &sig, &sig, message, &tree, &parents)
254 .map_err(err_msg)?;
255 }
256 Ok(())
257 }
258
259 fn head_message(&self) -> Option<String> {
260 let commit = self.repo.head().ok()?.peel_to_commit().ok()?;
261 Some(commit.message().unwrap_or("").to_string())
262 }
263
264 fn signature(&self) -> Option<(String, String)> {
265 let sig = self.repo.signature().ok()?;
266 Some((sig.name()?.to_string(), sig.email()?.to_string()))
267 }
268}
269
270fn file_change_from_delta(delta: &git2::DiffDelta) -> FileChange {
273 let new_path = delta.new_file().path().map(|p| p.display().to_string());
274 let old_path = delta.old_file().path().map(|p| p.display().to_string());
275 let status = status_from_delta(delta.status());
276 let path = new_path
277 .clone()
278 .or_else(|| old_path.clone())
279 .unwrap_or_default();
280 FileChange {
281 path,
282 old_path: old_path.filter(|o| Some(o) != new_path.as_ref()),
283 status,
284 }
285}
286
287fn err_msg(e: git2::Error) -> String {
289 e.message().to_string()
290}
291
292fn collect_refs(repo: &Repository) -> Result<HashMap<Oid, Vec<RefLabel>>, git2::Error> {
296 let mut map: HashMap<Oid, Vec<RefLabel>> = HashMap::new();
297
298 let head = repo.head().ok();
299 let head_branch = head
300 .as_ref()
301 .filter(|h| h.is_branch())
302 .and_then(|h| h.shorthand())
303 .map(str::to_string);
304 let detached = repo.head_detached().unwrap_or(false);
305
306 if detached && let Some(oid) = head.as_ref().and_then(|h| h.target()) {
307 map.entry(oid).or_default().push(RefLabel {
308 name: "HEAD".into(),
309 kind: RefKind::DetachedHead,
310 });
311 }
312
313 if let Ok(references) = repo.references() {
314 for reference in references.flatten() {
315 let Ok(commit) = reference.peel_to_commit() else {
316 continue;
317 };
318 let oid = commit.id();
319 let Some(name) = reference.shorthand().map(str::to_string) else {
320 continue;
321 };
322 let kind = if reference.is_tag() {
323 RefKind::Tag
324 } else if reference.is_remote() {
325 if name.ends_with("/HEAD") {
327 continue;
328 }
329 RefKind::RemoteBranch
330 } else if reference.is_branch() {
331 if head_branch.as_deref() == Some(name.as_str()) {
332 RefKind::Head
333 } else {
334 RefKind::LocalBranch
335 }
336 } else {
337 continue;
338 };
339 map.entry(oid).or_default().push(RefLabel { name, kind });
340 }
341 }
342
343 for labels in map.values_mut() {
345 labels.sort_by_key(|l| match l.kind {
346 RefKind::Head | RefKind::DetachedHead => 0,
347 RefKind::LocalBranch => 1,
348 RefKind::RemoteBranch => 2,
349 RefKind::Tag => 3,
350 });
351 }
352
353 Ok(map)
354}
355
356fn load_commits(
359 repo: &Repository,
360 refs: &HashMap<Oid, Vec<RefLabel>>,
361) -> Result<Vec<CommitInfo>, git2::Error> {
362 let mut revwalk = repo.revwalk()?;
363 revwalk.set_sorting(Sort::TIME | Sort::TOPOLOGICAL)?;
364 if revwalk.push_glob("refs/heads/*").is_err() {
367 let _ = revwalk.push_head();
368 }
369 let _ = revwalk.push_glob("refs/remotes/*");
370 let _ = revwalk.push_glob("refs/tags/*");
371 let _ = revwalk.push_head();
372
373 let mut commits = Vec::new();
374 for oid in revwalk {
375 let oid = oid?;
376 let commit = repo.find_commit(oid)?;
377 commits.push(commit_info(&commit, refs));
378 }
379 Ok(commits)
380}
381
382fn commit_info(commit: &Commit, refs: &HashMap<Oid, Vec<RefLabel>>) -> CommitInfo {
383 let id = commit.id().to_string();
384 let short_id = id.chars().take(8).collect();
385 let message = commit.message().unwrap_or("").to_string();
386 let summary = commit
387 .summary()
388 .map(str::to_string)
389 .unwrap_or_else(|| message.lines().next().unwrap_or("").to_string());
390 let author = commit.author();
391 let committer = commit.committer();
392 let time = author.when();
393
394 CommitInfo {
395 short_id,
396 summary,
397 message,
398 author_name: author.name().unwrap_or("").to_string(),
399 author_email: author.email().unwrap_or("").to_string(),
400 committer_name: committer.name().unwrap_or("").to_string(),
401 committer_email: committer.email().unwrap_or("").to_string(),
402 time_seconds: time.seconds(),
403 time_offset_minutes: time.offset_minutes(),
404 parents: commit.parent_ids().map(|p| p.to_string()).collect(),
405 refs: refs.get(&commit.id()).cloned().unwrap_or_default(),
406 id,
407 }
408}
409
410fn status_from_delta(delta: Delta) -> ChangeStatus {
411 match delta {
412 Delta::Added => ChangeStatus::Added,
413 Delta::Deleted => ChangeStatus::Deleted,
414 Delta::Modified => ChangeStatus::Modified,
415 Delta::Renamed => ChangeStatus::Renamed,
416 Delta::Copied => ChangeStatus::Copied,
417 Delta::Typechange => ChangeStatus::TypeChange,
418 Delta::Untracked => ChangeStatus::Untracked,
419 _ => ChangeStatus::Other,
420 }
421}
422
423fn render_diff(diff: git2::Diff) -> Diff {
428 let mut lines = Vec::new();
429 let _ = diff.print(DiffFormat::Patch, |_delta, _hunk, line| {
430 let content = String::from_utf8_lossy(line.content());
431 let content = content.trim_end_matches('\n');
432 match line.origin_value() {
433 DiffLineType::FileHeader => {
434 push_multiline(&mut lines, DiffLineKind::FileHeader, content)
435 }
436 DiffLineType::HunkHeader => {
437 push_multiline(&mut lines, DiffLineKind::HunkHeader, content)
438 }
439 DiffLineType::Context => {
440 lines.push(DiffLine::new(DiffLineKind::Context, format!(" {content}")))
441 }
442 DiffLineType::Addition => {
443 lines.push(DiffLine::new(DiffLineKind::Addition, format!("+{content}")))
444 }
445 DiffLineType::Deletion => {
446 lines.push(DiffLine::new(DiffLineKind::Deletion, format!("-{content}")))
447 }
448 DiffLineType::ContextEOFNL | DiffLineType::AddEOFNL | DiffLineType::DeleteEOFNL => {
449 lines.push(DiffLine::new(DiffLineKind::Meta, content.to_string()))
450 }
451 _ => push_multiline(&mut lines, DiffLineKind::Meta, content),
452 }
453 true
454 });
455 Diff { lines }
456}
457
458fn push_multiline(out: &mut Vec<DiffLine>, kind: DiffLineKind, content: &str) {
459 for line in content.split('\n') {
460 out.push(DiffLine::new(kind, line.to_string()));
461 }
462}