1use std::collections::HashMap;
5use std::fs;
6use std::path::{Path, PathBuf};
7
8use crate::model::{DiffLine, DiffModel, FileDiff, FileStatus, Hunk, HunkId, LineKind, hunk_id};
9use crate::vcs::{BranchInfo, HeadInfo, LogEntry, NetworkOp, StatusModel, Vcs, VcsError};
10
11pub const DEFAULT_CONTEXT_LINES: u32 = 3;
13
14pub struct GitVcs {
15 repo: git2::Repository,
16 context_lines: u32,
17}
18
19impl GitVcs {
20 pub fn open(root: &Path) -> Result<Self, VcsError> {
21 Self::open_with_context(root, DEFAULT_CONTEXT_LINES)
22 }
23
24 pub fn open_with_context(root: &Path, context_lines: u32) -> Result<Self, VcsError> {
26 let repo = git2::Repository::open(root)?;
27 if repo.workdir().is_none() {
28 return Err(VcsError::NoWorkdir);
29 }
30 Ok(Self {
31 repo,
32 context_lines,
33 })
34 }
35
36 fn head_tree(&self) -> Result<Option<git2::Tree<'_>>, VcsError> {
38 match self.repo.head() {
39 Ok(head) => Ok(Some(head.peel_to_tree()?)),
40 Err(err) if err.code() == git2::ErrorCode::UnbornBranch => Ok(None),
41 Err(err) => Err(err.into()),
42 }
43 }
44
45 fn workdir_path(&self) -> Result<&Path, VcsError> {
46 self.repo.workdir().ok_or(VcsError::NoWorkdir)
47 }
48
49 fn has_tracked_changes(&self) -> Result<bool, VcsError> {
53 let mut opts = git2::StatusOptions::new();
54 opts.include_untracked(false).include_ignored(false);
55 let statuses = self.repo.statuses(Some(&mut opts))?;
56 Ok(statuses.iter().next().is_some())
57 }
58}
59
60impl Vcs for GitVcs {
61 fn git_dir(&self) -> Result<PathBuf, VcsError> {
62 Ok(self.repo.path().to_path_buf())
65 }
66
67 fn head(&self) -> Result<HeadInfo, VcsError> {
68 match self.repo.head() {
69 Ok(head) => {
70 let branch = if head.is_branch() {
71 Some(head.shorthand()?.to_owned())
72 } else {
73 None
74 };
75 let commit = head.peel_to_commit()?;
76 let upstream = branch.as_deref().and_then(|name| {
77 let local = self.repo.find_branch(name, git2::BranchType::Local).ok()?;
78 let upstream = local.upstream().ok()?;
79 upstream.name().ok().flatten().map(str::to_owned)
80 });
81 Ok(HeadInfo {
82 branch,
83 oid7: short7(&commit.id().to_string()),
84 subject: commit.summary()?.unwrap_or_default().to_owned(),
85 upstream,
86 })
87 }
88 Err(err) if err.code() == git2::ErrorCode::UnbornBranch => {
89 let branch = self
90 .repo
91 .find_reference("HEAD")
92 .ok()
93 .and_then(|r| r.symbolic_target().ok().flatten().map(str::to_owned))
94 .and_then(|t| t.strip_prefix("refs/heads/").map(str::to_owned));
95 Ok(HeadInfo {
96 branch,
97 oid7: String::new(),
98 subject: String::new(),
99 upstream: None,
100 })
101 }
102 Err(err) => Err(err.into()),
103 }
104 }
105
106 fn status(&self) -> Result<StatusModel, VcsError> {
107 let mut workdir = self
110 .repo
111 .diff_index_to_workdir(None, Some(&mut workdir_diff_options(self.context_lines)))?;
112 let workdir_model = diff_to_model(&self.repo, &mut workdir)?;
113 let (untracked, unstaged): (Vec<_>, Vec<_>) = workdir_model
114 .files
115 .into_iter()
116 .partition(|f| f.status == FileStatus::Untracked);
117
118 let head_tree = self.head_tree()?;
119 let mut opts = git2::DiffOptions::new();
120 opts.context_lines(self.context_lines);
121 let mut staged = self
122 .repo
123 .diff_tree_to_index(head_tree.as_ref(), None, Some(&mut opts))?;
124 let staged = diff_to_model(&self.repo, &mut staged)?;
125
126 Ok(StatusModel {
127 untracked: DiffModel { files: untracked },
128 unstaged: DiffModel { files: unstaged },
129 staged,
130 })
131 }
132
133 fn working_tree_diff(&self) -> Result<DiffModel, VcsError> {
134 let head_tree = self.head_tree()?;
135 let mut diff = self.repo.diff_tree_to_workdir_with_index(
136 head_tree.as_ref(),
137 Some(&mut workdir_diff_options(self.context_lines)),
138 )?;
139 let mut find = git2::DiffFindOptions::new();
140 find.renames(true);
141 diff.find_similar(Some(&mut find))?;
142 diff_to_model(&self.repo, &mut diff)
143 }
144
145 fn commit_diff(&self, oid: &str) -> Result<DiffModel, VcsError> {
146 let oid = git2::Oid::from_str(oid)?;
147 let commit = self.repo.find_commit(oid)?;
148 let tree = commit.tree()?;
149 let parent_tree = commit.parent(0).ok().map(|p| p.tree()).transpose()?;
151 let mut opts = git2::DiffOptions::new();
152 opts.context_lines(self.context_lines);
153 let mut diff =
154 self.repo
155 .diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), Some(&mut opts))?;
156 diff_to_model(&self.repo, &mut diff)
157 }
158
159 fn tree_diff(&self, base_oid: &str, newest_oid: &str) -> Result<DiffModel, VcsError> {
160 let base = self.repo.find_commit(git2::Oid::from_str(base_oid)?)?;
161 let newest = self.repo.find_commit(git2::Oid::from_str(newest_oid)?)?;
162 let mut opts = git2::DiffOptions::new();
163 opts.context_lines(self.context_lines);
164 let mut diff = self.repo.diff_tree_to_tree(
165 Some(&base.tree()?),
166 Some(&newest.tree()?),
167 Some(&mut opts),
168 )?;
169 diff_to_model(&self.repo, &mut diff)
170 }
171
172 fn merge_base(&self, a: &str, b: &str) -> Result<String, VcsError> {
173 let base = self
174 .repo
175 .merge_base(git2::Oid::from_str(a)?, git2::Oid::from_str(b)?)?;
176 Ok(base.to_string())
177 }
178
179 fn resolve(&self, revision: &str) -> Result<String, VcsError> {
180 let object = self.repo.revparse_single(revision)?;
181 let commit = object.peel_to_commit()?;
182 Ok(commit.id().to_string())
183 }
184
185 fn range_diff(&self, oldest_oid: &str, newest_oid: &str) -> Result<DiffModel, VcsError> {
186 let oldest = self.repo.find_commit(git2::Oid::from_str(oldest_oid)?)?;
187 let newest = self.repo.find_commit(git2::Oid::from_str(newest_oid)?)?;
188 let newest_tree = newest.tree()?;
189 let base_tree = oldest.parent(0).ok().map(|p| p.tree()).transpose()?;
193 let mut opts = git2::DiffOptions::new();
194 opts.context_lines(self.context_lines);
195 let mut diff =
196 self.repo
197 .diff_tree_to_tree(base_tree.as_ref(), Some(&newest_tree), Some(&mut opts))?;
198 diff_to_model(&self.repo, &mut diff)
199 }
200
201 fn log(&self, limit: usize) -> Result<Vec<LogEntry>, VcsError> {
202 if self.head_tree()?.is_none() {
203 return Ok(Vec::new());
204 }
205 let mut refs_by_oid: HashMap<git2::Oid, Vec<String>> = HashMap::new();
206 for reference in self.repo.references()?.flatten() {
207 let Ok(name) = reference.shorthand().map(str::to_owned) else {
208 continue;
209 };
210 let Some(target) = reference.peel_to_commit().ok().map(|c| c.id()) else {
212 continue;
213 };
214 refs_by_oid.entry(target).or_default().push(name);
215 }
216
217 let mut walk = self.repo.revwalk()?;
218 walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)?;
219 walk.push_head()?;
220 let mut entries = Vec::new();
221 for oid in walk.take(limit) {
222 let oid = oid?;
223 let commit = self.repo.find_commit(oid)?;
224 let full = oid.to_string();
225 entries.push(LogEntry {
226 oid7: short7(&full),
227 oid: full,
228 refs: refs_by_oid.get(&oid).cloned().unwrap_or_default(),
229 subject: commit.summary()?.unwrap_or_default().to_owned(),
230 author: commit.author().name().unwrap_or_default().to_owned(),
231 time_unix: commit.time().seconds(),
232 });
233 }
234 Ok(entries)
235 }
236
237 fn branches(&self) -> Result<Vec<BranchInfo>, VcsError> {
238 let mut out = Vec::new();
239 for entry in self.repo.branches(Some(git2::BranchType::Local))? {
240 let (branch, _) = entry?;
241 let Some(name) = branch.name()?.map(str::to_owned) else {
242 continue;
243 };
244 out.push(BranchInfo {
245 name,
246 is_head: branch.is_head(),
247 });
248 }
249 Ok(out)
250 }
251
252 fn stage(&self, rel: &Path) -> Result<(), VcsError> {
253 let root = self.workdir_path()?;
254 let mut index = self.repo.index()?;
255 if root.join(rel).exists() {
256 index.add_path(rel)?;
257 } else {
258 index.remove_path(rel)?;
259 }
260 index.write()?;
261 Ok(())
262 }
263
264 fn stage_hunk(&self, rel: &Path, hunk: &HunkId) -> Result<(), VcsError> {
265 let diff = self
266 .repo
267 .diff_index_to_workdir(None, Some(&mut workdir_diff_options(self.context_lines)))?;
268 let patch = synthesize_patch(&diff, rel, hunk, false)?;
269 let diff = git2::Diff::from_buffer(&patch)?;
270 self.repo.apply(&diff, git2::ApplyLocation::Index, None)?;
271 Ok(())
272 }
273
274 fn unstage(&self, rel: &Path) -> Result<(), VcsError> {
275 match self.repo.head() {
276 Ok(head) => {
277 let target = head.peel(git2::ObjectType::Commit)?;
278 self.repo.reset_default(Some(&target), [rel])?;
279 }
280 Err(err) if err.code() == git2::ErrorCode::UnbornBranch => {
282 let mut index = self.repo.index()?;
283 index.remove_path(rel)?;
284 index.write()?;
285 }
286 Err(err) => return Err(err.into()),
287 }
288 Ok(())
289 }
290
291 fn unstage_hunk(&self, rel: &Path, hunk: &HunkId) -> Result<(), VcsError> {
292 let head_tree = self.head_tree()?;
293 let mut opts = git2::DiffOptions::new();
294 opts.context_lines(self.context_lines);
295 let diff = self
296 .repo
297 .diff_tree_to_index(head_tree.as_ref(), None, Some(&mut opts))?;
298 let patch = synthesize_patch(&diff, rel, hunk, true)?;
299 let diff = git2::Diff::from_buffer(&patch)?;
300 self.repo.apply(&diff, git2::ApplyLocation::Index, None)?;
301 Ok(())
302 }
303
304 fn discard(&self, rel: &Path) -> Result<(), VcsError> {
305 let head_tree = self.head_tree()?;
306 let mut opts = git2::DiffOptions::new();
307 opts.pathspec(rel).disable_pathspec_match(true);
308 let staged = self
309 .repo
310 .diff_tree_to_index(head_tree.as_ref(), None, Some(&mut opts))?;
311 if staged.deltas().len() > 0 {
312 return Err(VcsError::Rejected(
313 "file has staged changes; unstage first".into(),
314 ));
315 }
316 let status = self.repo.status_file(rel)?;
317 if status.contains(git2::Status::WT_NEW) {
318 fs::remove_file(self.workdir_path()?.join(rel))?;
319 return Ok(());
320 }
321 let mut checkout = git2::build::CheckoutBuilder::new();
328 checkout.path(rel).force().update_index(true);
329 self.repo.checkout_head(Some(&mut checkout))?;
330 Ok(())
331 }
332
333 fn commit(&self, message: &str) -> Result<String, VcsError> {
334 if message.trim().is_empty() {
335 return Err(VcsError::Rejected("empty commit message".into()));
336 }
337 let mut index = self.repo.index()?;
338 let tree_id = index.write_tree()?;
339 let tree = self.repo.find_tree(tree_id)?;
340 let signature = self.repo.signature()?;
341 let parent = match self.repo.head() {
342 Ok(head) => Some(head.peel_to_commit()?),
343 Err(err) if err.code() == git2::ErrorCode::UnbornBranch => None,
344 Err(err) => return Err(err.into()),
345 };
346 let parents: Vec<&git2::Commit<'_>> = parent.iter().collect();
347 let oid = self.repo.commit(
348 Some("HEAD"),
349 &signature,
350 &signature,
351 message,
352 &tree,
353 &parents,
354 )?;
355 Ok(oid.to_string())
356 }
357
358 fn head_message(&self) -> Result<String, VcsError> {
359 let commit = self.repo.head()?.peel_to_commit()?;
360 Ok(commit.message().unwrap_or_default().to_owned())
361 }
362
363 fn amend(&self, message: Option<&str>, use_index: bool) -> Result<String, VcsError> {
364 if let Some(message) = message
365 && message.trim().is_empty()
366 {
367 return Err(VcsError::Rejected("empty commit message".into()));
368 }
369 let head = self.repo.head()?.peel_to_commit()?;
370 let tree = if use_index {
373 let mut index = self.repo.index()?;
374 let tree_id = index.write_tree()?;
375 self.repo.find_tree(tree_id)?
376 } else {
377 head.tree()?
378 };
379 let oid = head.amend(Some("HEAD"), None, None, None, message, Some(&tree))?;
380 Ok(oid.to_string())
381 }
382
383 fn create_branch(&self, name: &str, checkout: bool) -> Result<(), VcsError> {
384 let head = self.repo.head()?.peel_to_commit()?;
385 self.repo.branch(name, &head, false)?;
386 if checkout {
387 self.checkout(name)?;
388 }
389 Ok(())
390 }
391
392 fn delete_branch(&self, name: &str) -> Result<(), VcsError> {
393 let mut branch = self.repo.find_branch(name, git2::BranchType::Local)?;
394 if branch.is_head() {
395 return Err(VcsError::Rejected(
396 "cannot delete the checked-out branch".into(),
397 ));
398 }
399 branch.delete()?;
400 Ok(())
401 }
402
403 fn checkout(&self, name: &str) -> Result<(), VcsError> {
404 let branch = self.repo.find_branch(name, git2::BranchType::Local)?;
405 let target = branch.get().peel(git2::ObjectType::Commit)?;
406 self.repo.checkout_tree(&target, None)?;
408 self.repo.set_head(&format!("refs/heads/{name}"))?;
409 Ok(())
410 }
411
412 fn stash_push(&self, message: Option<&str>) -> Result<(), VcsError> {
413 if !self.has_tracked_changes()? {
414 return Err(VcsError::Rejected("nothing to stash".into()));
415 }
416 let mut repo = git2::Repository::open(self.workdir_path()?)?;
419 let signature = repo.signature()?;
420 repo.stash_save2(&signature, message, None)?;
421 Ok(())
422 }
423
424 fn stash_pop(&self) -> Result<(), VcsError> {
425 let mut repo = git2::Repository::open(self.workdir_path()?)?;
426 match repo.stash_pop(0, None) {
427 Ok(()) => Ok(()),
428 Err(err) if err.code() == git2::ErrorCode::NotFound => {
429 Err(VcsError::Rejected("no stash to pop".into()))
430 }
431 Err(err) if err.code() == git2::ErrorCode::Conflict => Err(VcsError::Rejected(
434 "stash applied with conflicts; resolve them — the stash was kept".into(),
435 )),
436 Err(err) => Err(err.into()),
437 }
438 }
439
440 fn network_argv(&self, op: NetworkOp) -> Vec<String> {
441 let args: &[&str] = match op {
444 NetworkOp::Fetch => &["fetch"],
445 NetworkOp::FetchAll => &["fetch", "--all"],
446 };
447 std::iter::once("git")
448 .chain(args.iter().copied())
449 .map(str::to_owned)
450 .collect()
451 }
452
453 fn workdir(&self) -> Result<PathBuf, VcsError> {
454 Ok(self.workdir_path()?.to_path_buf())
455 }
456
457 fn remote_url(&self, name: &str) -> Result<Option<String>, VcsError> {
458 match self.repo.find_remote(name) {
459 Ok(remote) => Ok(remote.url().ok().map(str::to_owned)),
461 Err(err) if err.code() == git2::ErrorCode::NotFound => Ok(None),
462 Err(err) => Err(err.into()),
463 }
464 }
465
466 fn remotes(&self) -> Result<Vec<String>, VcsError> {
467 let array = self.repo.remotes()?;
468 let mut names = Vec::new();
469 for i in 0..array.len() {
470 if let Ok(Some(name)) = array.get(i) {
471 names.push(name.to_owned());
472 }
473 }
474 Ok(names)
475 }
476}
477
478fn synthesize_patch(
483 diff: &git2::Diff<'_>,
484 rel: &Path,
485 target: &HunkId,
486 reverse: bool,
487) -> Result<Vec<u8>, VcsError> {
488 let rel = rel.to_string_lossy();
489 for idx in 0..diff.deltas().len() {
490 let Some(patch) = git2::Patch::from_diff(diff, idx)? else {
491 continue;
492 };
493 let delta = patch.delta();
494 if delta.flags().is_binary() || delta_new_path(&delta) != rel {
495 continue;
496 }
497 for h in 0..patch.num_hunks() {
498 if hunk_id(&rel, &hunk_model_lines(&patch, h)?) == *target {
499 return render_hunk_patch(&patch, h, &rel, delta.status(), reverse);
500 }
501 }
502 }
503 Err(VcsError::Rejected("hunk not found (diff changed?)".into()))
504}
505
506fn render_hunk_patch(
507 patch: &git2::Patch<'_>,
508 h: usize,
509 rel: &str,
510 status: git2::Delta,
511 reverse: bool,
512) -> Result<Vec<u8>, VcsError> {
513 let added = matches!(status, git2::Delta::Added | git2::Delta::Untracked);
514 let deleted = status == git2::Delta::Deleted;
515 let mut out = Vec::new();
516 out.extend_from_slice(format!("diff --git a/{rel} b/{rel}\n").as_bytes());
517 if (added && !reverse) || (deleted && reverse) {
521 out.extend_from_slice(
522 format!("new file mode 100644\n--- /dev/null\n+++ b/{rel}\n").as_bytes(),
523 );
524 } else if (deleted && !reverse) || (added && reverse) {
525 out.extend_from_slice(
526 format!("deleted file mode 100644\n--- a/{rel}\n+++ /dev/null\n").as_bytes(),
527 );
528 } else {
529 out.extend_from_slice(format!("--- a/{rel}\n+++ b/{rel}\n").as_bytes());
530 }
531 let (hunk, line_count) = patch.hunk(h)?;
532 let (old, new) = (
533 (hunk.old_start(), hunk.old_lines()),
534 (hunk.new_start(), hunk.new_lines()),
535 );
536 let ((minus_start, minus_lines), (plus_start, plus_lines)) =
537 if reverse { (new, old) } else { (old, new) };
538 out.extend_from_slice(
539 format!("@@ -{minus_start},{minus_lines} +{plus_start},{plus_lines} @@\n").as_bytes(),
540 );
541 for l in 0..line_count {
542 let line = patch.line_in_hunk(h, l)?;
543 let origin = match (line.origin(), reverse) {
544 (' ', _) => Some(b' '),
545 ('+', false) | ('-', true) => Some(b'+'),
546 ('-', false) | ('+', true) => Some(b'-'),
547 ('=' | '>' | '<', _) => None,
551 _ => continue,
552 };
553 if let Some(origin) = origin {
554 out.push(origin);
555 }
556 out.extend_from_slice(line.content());
557 }
558 Ok(out)
559}
560
561fn workdir_diff_options(context_lines: u32) -> git2::DiffOptions {
562 let mut opts = git2::DiffOptions::new();
563 opts.include_untracked(true)
564 .recurse_untracked_dirs(true)
565 .show_untracked_content(true)
566 .context_lines(context_lines);
567 opts
568}
569
570fn short7(oid: &str) -> String {
571 oid.get(..7).unwrap_or(oid).to_owned()
572}
573
574fn diff_to_model(
575 repo: &git2::Repository,
576 diff: &mut git2::Diff<'_>,
577) -> Result<DiffModel, VcsError> {
578 let mut files = Vec::new();
579 for idx in 0..diff.deltas().len() {
580 if let Some(file) = build_file(repo, diff, idx)? {
581 files.push(file);
582 }
583 }
584 Ok(DiffModel { files })
588}
589
590fn build_file(
591 repo: &git2::Repository,
592 diff: &mut git2::Diff<'_>,
593 idx: usize,
594) -> Result<Option<FileDiff>, VcsError> {
595 let Some(patch) = git2::Patch::from_diff(diff, idx)? else {
596 return Ok(build_binary_file(diff, idx));
598 };
599 let delta = patch.delta();
600 if delta.flags().is_binary() {
601 return Ok(build_binary_file(diff, idx));
602 }
603 let file_path = delta_new_path(&delta);
604 let status = map_status(delta.status());
605 let old_path = if status == FileStatus::Renamed {
606 delta
607 .old_file()
608 .path()
609 .map(|p| p.to_string_lossy().into_owned())
610 } else {
611 None
612 };
613
614 let old_text = blob_text(repo, delta.old_file().id());
615 let new_text = new_side_text(repo, &delta, &file_path);
616
617 let hunks = patch_hunks(&patch, &file_path)?;
618
619 Ok(Some(FileDiff {
620 path: file_path,
621 old_path,
622 status,
623 binary: false,
624 old_text,
625 new_text,
626 hunks,
627 hashes: crate::model::HashCache::default(),
628 }))
629}
630
631pub fn rehunk_file(file: &FileDiff, context: u32) -> Option<Vec<Hunk>> {
636 if file.binary {
637 return None;
638 }
639 let (old, new) = (file.old_text.as_deref()?, file.new_text.as_deref()?);
640 let as_path = Path::new(&file.path);
641 let cap = u32::try_from(old.lines().count().max(new.lines().count())).unwrap_or(u32::MAX);
645 let mut opts = git2::DiffOptions::new();
646 opts.context_lines(context.min(cap));
647 let patch = git2::Patch::from_buffers(
648 old.as_bytes(),
649 Some(as_path),
650 new.as_bytes(),
651 Some(as_path),
652 Some(&mut opts),
653 )
654 .ok()?;
655 patch_hunks(&patch, &file.path).ok()
656}
657
658fn patch_hunks(patch: &git2::Patch<'_>, file_path: &str) -> Result<Vec<Hunk>, VcsError> {
661 let mut hunks = Vec::with_capacity(patch.num_hunks());
662 for h in 0..patch.num_hunks() {
663 let (hunk, _) = patch.hunk(h)?;
664 let lines = hunk_model_lines(patch, h)?;
665 let id = hunk_id(file_path, &lines);
666 hunks.push(Hunk {
667 id,
668 old_start: hunk.old_start(),
669 old_lines: hunk.old_lines(),
670 new_start: hunk.new_start(),
671 new_lines: hunk.new_lines(),
672 context: hunk_context(&hunk),
673 lines,
674 });
675 }
676 Ok(hunks)
677}
678
679fn hunk_context(hunk: &git2::DiffHunk<'_>) -> String {
683 let header = String::from_utf8_lossy(hunk.header());
684 match header.split_once(" @@") {
685 Some((_, rest)) => rest.trim_matches(['\n', '\r', ' ']).to_owned(),
686 None => String::new(),
687 }
688}
689
690fn hunk_model_lines(patch: &git2::Patch<'_>, h: usize) -> Result<Vec<DiffLine>, VcsError> {
694 let (_, line_count) = patch.hunk(h)?;
695 let mut lines = Vec::with_capacity(line_count);
696 for l in 0..line_count {
697 let line = patch.line_in_hunk(h, l)?;
698 let kind = match line.origin() {
699 '-' => LineKind::Deleted,
700 '+' => LineKind::Added,
701 ' ' => LineKind::Context,
702 _ => continue,
704 };
705 let text = String::from_utf8_lossy(line.content())
706 .trim_end_matches(['\n', '\r'])
707 .to_owned();
708 lines.push(DiffLine::new(
709 kind,
710 line.old_lineno(),
711 line.new_lineno(),
712 text,
713 ));
714 }
715 Ok(lines)
716}
717
718fn build_binary_file(diff: &git2::Diff<'_>, idx: usize) -> Option<FileDiff> {
719 let delta = diff.get_delta(idx)?;
720 Some(FileDiff {
721 path: delta_new_path(&delta),
722 old_path: None,
723 status: map_status(delta.status()),
724 binary: true,
725 old_text: None,
726 new_text: None,
727 hunks: Vec::new(),
728 hashes: crate::model::HashCache::default(),
729 })
730}
731
732fn delta_new_path(delta: &git2::DiffDelta<'_>) -> String {
733 delta
734 .new_file()
735 .path()
736 .or_else(|| delta.old_file().path())
737 .map(|p| p.to_string_lossy().into_owned())
738 .unwrap_or_default()
739}
740
741fn map_status(status: git2::Delta) -> FileStatus {
742 match status {
743 git2::Delta::Added => FileStatus::Added,
744 git2::Delta::Deleted => FileStatus::Deleted,
745 git2::Delta::Renamed => FileStatus::Renamed,
746 git2::Delta::Untracked => FileStatus::Untracked,
747 _ => FileStatus::Modified,
748 }
749}
750
751fn blob_text(repo: &git2::Repository, oid: git2::Oid) -> Option<String> {
752 if oid.is_zero() {
753 return None;
754 }
755 let blob = repo.find_blob(oid).ok()?;
756 if blob.is_binary() {
757 return None;
758 }
759 String::from_utf8(blob.content().to_vec()).ok()
760}
761
762fn new_side_text(
765 repo: &git2::Repository,
766 delta: &git2::DiffDelta<'_>,
767 rel: &str,
768) -> Option<String> {
769 if delta.status() == git2::Delta::Deleted {
770 return None;
771 }
772 if let Some(text) = blob_text(repo, delta.new_file().id()) {
773 return Some(text);
774 }
775 let root = repo.workdir()?;
776 fs::read_to_string(root.join(rel)).ok()
777}