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 workdir_diff(&self, base: Option<&git2::Tree<'_>>) -> Result<DiffModel, VcsError> {
52 let mut diff = self.repo.diff_tree_to_workdir_with_index(
53 base,
54 Some(&mut workdir_diff_options(self.context_lines)),
55 )?;
56 let mut find = git2::DiffFindOptions::new();
57 find.renames(true);
58 diff.find_similar(Some(&mut find))?;
59 diff_to_model(&self.repo, &mut diff)
60 }
61
62 fn has_tracked_changes(&self) -> Result<bool, VcsError> {
66 let mut opts = git2::StatusOptions::new();
67 opts.include_untracked(false).include_ignored(false);
68 let statuses = self.repo.statuses(Some(&mut opts))?;
69 Ok(statuses.iter().next().is_some())
70 }
71}
72
73impl Vcs for GitVcs {
74 fn git_dir(&self) -> Result<PathBuf, VcsError> {
75 Ok(self.repo.path().to_path_buf())
78 }
79
80 fn head(&self) -> Result<HeadInfo, VcsError> {
81 match self.repo.head() {
82 Ok(head) => {
83 let branch = if head.is_branch() {
84 Some(head.shorthand()?.to_owned())
85 } else {
86 None
87 };
88 let commit = head.peel_to_commit()?;
89 let upstream = branch.as_deref().and_then(|name| {
90 let local = self.repo.find_branch(name, git2::BranchType::Local).ok()?;
91 let upstream = local.upstream().ok()?;
92 upstream.name().ok().flatten().map(str::to_owned)
93 });
94 Ok(HeadInfo {
95 branch,
96 oid7: short7(&commit.id().to_string()),
97 subject: commit.summary()?.unwrap_or_default().to_owned(),
98 upstream,
99 })
100 }
101 Err(err) if err.code() == git2::ErrorCode::UnbornBranch => {
102 let branch = self
103 .repo
104 .find_reference("HEAD")
105 .ok()
106 .and_then(|r| r.symbolic_target().ok().flatten().map(str::to_owned))
107 .and_then(|t| t.strip_prefix("refs/heads/").map(str::to_owned));
108 Ok(HeadInfo {
109 branch,
110 oid7: String::new(),
111 subject: String::new(),
112 upstream: None,
113 })
114 }
115 Err(err) => Err(err.into()),
116 }
117 }
118
119 fn status(&self) -> Result<StatusModel, VcsError> {
120 let mut workdir = self
123 .repo
124 .diff_index_to_workdir(None, Some(&mut workdir_diff_options(self.context_lines)))?;
125 let workdir_model = diff_to_model(&self.repo, &mut workdir)?;
126 let (untracked, unstaged): (Vec<_>, Vec<_>) = workdir_model
127 .files
128 .into_iter()
129 .partition(|f| f.status == FileStatus::Untracked);
130
131 let head_tree = self.head_tree()?;
132 let mut opts = git2::DiffOptions::new();
133 opts.context_lines(self.context_lines);
134 let mut staged = self
135 .repo
136 .diff_tree_to_index(head_tree.as_ref(), None, Some(&mut opts))?;
137 let staged = diff_to_model(&self.repo, &mut staged)?;
138
139 Ok(StatusModel {
140 untracked: DiffModel { files: untracked },
141 unstaged: DiffModel { files: unstaged },
142 staged,
143 })
144 }
145
146 fn working_tree_diff(&self) -> Result<DiffModel, VcsError> {
147 self.workdir_diff(self.head_tree()?.as_ref())
148 }
149
150 fn tree_to_workdir_diff(&self, base_oid: &str) -> Result<DiffModel, VcsError> {
151 let base = self.repo.find_commit(git2::Oid::from_str(base_oid)?)?;
152 self.workdir_diff(Some(&base.tree()?))
153 }
154
155 fn commit_diff(&self, oid: &str) -> Result<DiffModel, VcsError> {
156 let oid = git2::Oid::from_str(oid)?;
157 let commit = self.repo.find_commit(oid)?;
158 let tree = commit.tree()?;
159 let parent_tree = commit.parent(0).ok().map(|p| p.tree()).transpose()?;
161 let mut opts = git2::DiffOptions::new();
162 opts.context_lines(self.context_lines);
163 let mut diff =
164 self.repo
165 .diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), Some(&mut opts))?;
166 diff_to_model(&self.repo, &mut diff)
167 }
168
169 fn tree_diff(&self, base_oid: &str, newest_oid: &str) -> Result<DiffModel, VcsError> {
170 let base = self.repo.find_commit(git2::Oid::from_str(base_oid)?)?;
171 let newest = self.repo.find_commit(git2::Oid::from_str(newest_oid)?)?;
172 let mut opts = git2::DiffOptions::new();
173 opts.context_lines(self.context_lines);
174 let mut diff = self.repo.diff_tree_to_tree(
175 Some(&base.tree()?),
176 Some(&newest.tree()?),
177 Some(&mut opts),
178 )?;
179 diff_to_model(&self.repo, &mut diff)
180 }
181
182 fn merge_base(&self, a: &str, b: &str) -> Result<String, VcsError> {
183 let base = self
184 .repo
185 .merge_base(git2::Oid::from_str(a)?, git2::Oid::from_str(b)?)?;
186 Ok(base.to_string())
187 }
188
189 fn resolve(&self, revision: &str) -> Result<String, VcsError> {
190 let object = self.repo.revparse_single(revision)?;
191 let commit = object.peel_to_commit()?;
192 Ok(commit.id().to_string())
193 }
194
195 fn range_diff(&self, oldest_oid: &str, newest_oid: &str) -> Result<DiffModel, VcsError> {
196 let oldest = self.repo.find_commit(git2::Oid::from_str(oldest_oid)?)?;
197 let newest = self.repo.find_commit(git2::Oid::from_str(newest_oid)?)?;
198 let newest_tree = newest.tree()?;
199 let base_tree = oldest.parent(0).ok().map(|p| p.tree()).transpose()?;
203 let mut opts = git2::DiffOptions::new();
204 opts.context_lines(self.context_lines);
205 let mut diff =
206 self.repo
207 .diff_tree_to_tree(base_tree.as_ref(), Some(&newest_tree), Some(&mut opts))?;
208 diff_to_model(&self.repo, &mut diff)
209 }
210
211 fn log(&self, limit: usize) -> Result<Vec<LogEntry>, VcsError> {
212 if self.head_tree()?.is_none() {
213 return Ok(Vec::new());
214 }
215 let mut refs_by_oid: HashMap<git2::Oid, Vec<String>> = HashMap::new();
216 for reference in self.repo.references()?.flatten() {
217 let Ok(name) = reference.shorthand().map(str::to_owned) else {
218 continue;
219 };
220 let Some(target) = reference.peel_to_commit().ok().map(|c| c.id()) else {
222 continue;
223 };
224 refs_by_oid.entry(target).or_default().push(name);
225 }
226
227 let mut walk = self.repo.revwalk()?;
228 walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)?;
229 walk.push_head()?;
230 let mut entries = Vec::new();
231 for oid in walk.take(limit) {
232 let oid = oid?;
233 let commit = self.repo.find_commit(oid)?;
234 let full = oid.to_string();
235 entries.push(LogEntry {
236 oid7: short7(&full),
237 oid: full,
238 refs: refs_by_oid.get(&oid).cloned().unwrap_or_default(),
239 subject: commit.summary()?.unwrap_or_default().to_owned(),
240 author: commit.author().name().unwrap_or_default().to_owned(),
241 time_unix: commit.time().seconds(),
242 });
243 }
244 Ok(entries)
245 }
246
247 fn default_branch(&self, remote: &str) -> Result<Option<String>, VcsError> {
248 let head_ref = format!("refs/remotes/{remote}/HEAD");
251 let prefix = format!("refs/remotes/{remote}/");
252 if let Ok(reference) = self.repo.find_reference(&head_ref)
253 && let Ok(Some(target)) = reference.symbolic_target()
254 && let Some(name) = target.strip_prefix(prefix.as_str())
256 {
257 return Ok(Some(name.to_owned()));
258 }
259 for name in ["main", "master"] {
260 if self
261 .repo
262 .find_branch(name, git2::BranchType::Local)
263 .or_else(|_| {
264 self.repo
265 .find_branch(&format!("{remote}/{name}"), git2::BranchType::Remote)
266 })
267 .is_ok()
268 {
269 return Ok(Some(name.to_owned()));
270 }
271 }
272 Ok(None)
273 }
274
275 fn commits_between(&self, base: &str, head: &str) -> Result<Vec<LogEntry>, VcsError> {
276 let (base, head) = (
277 self.repo.revparse_single(base)?,
278 self.repo.revparse_single(head)?,
279 );
280 let mut walk = self.repo.revwalk()?;
281 walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)?;
282 walk.push(head.id())?;
283 walk.hide(base.id())?;
284 let mut entries = Vec::new();
285 for oid in walk {
286 let oid = oid?;
287 let commit = self.repo.find_commit(oid)?;
288 let full = oid.to_string();
289 entries.push(LogEntry {
290 oid7: short7(&full),
291 oid: full,
292 refs: Vec::new(),
293 subject: commit.summary()?.unwrap_or_default().to_owned(),
294 author: commit.author().name().unwrap_or_default().to_owned(),
295 time_unix: commit.time().seconds(),
296 });
297 }
298 Ok(entries)
299 }
300
301 fn branches(&self) -> Result<Vec<BranchInfo>, VcsError> {
302 let mut out = Vec::new();
303 for entry in self.repo.branches(Some(git2::BranchType::Local))? {
304 let (branch, _) = entry?;
305 let Some(name) = branch.name()?.map(str::to_owned) else {
306 continue;
307 };
308 out.push(BranchInfo {
309 name,
310 is_head: branch.is_head(),
311 });
312 }
313 Ok(out)
314 }
315
316 fn all_branches(&self) -> Result<Vec<String>, VcsError> {
317 let mut out = Vec::new();
318 for entry in self.repo.branches(None)? {
319 let (branch, _) = entry?;
320 let Some(name) = branch.name()?.map(str::to_owned) else {
321 continue;
322 };
323 if name.ends_with("/HEAD") {
325 continue;
326 }
327 out.push(name);
328 }
329 Ok(out)
330 }
331
332 fn stage(&self, rel: &Path) -> Result<(), VcsError> {
333 let root = self.workdir_path()?;
334 let mut index = self.repo.index()?;
335 if root.join(rel).exists() {
336 index.add_path(rel)?;
337 } else {
338 index.remove_path(rel)?;
339 }
340 index.write()?;
341 Ok(())
342 }
343
344 fn stage_hunk(&self, rel: &Path, hunk: &HunkId) -> Result<(), VcsError> {
345 let diff = self
346 .repo
347 .diff_index_to_workdir(None, Some(&mut workdir_diff_options(self.context_lines)))?;
348 let patch = synthesize_patch(&diff, rel, hunk, false)?;
349 let diff = git2::Diff::from_buffer(&patch)?;
350 self.repo.apply(&diff, git2::ApplyLocation::Index, None)?;
351 Ok(())
352 }
353
354 fn unstage(&self, rel: &Path) -> Result<(), VcsError> {
355 match self.repo.head() {
356 Ok(head) => {
357 let target = head.peel(git2::ObjectType::Commit)?;
358 self.repo.reset_default(Some(&target), [rel])?;
359 }
360 Err(err) if err.code() == git2::ErrorCode::UnbornBranch => {
362 let mut index = self.repo.index()?;
363 index.remove_path(rel)?;
364 index.write()?;
365 }
366 Err(err) => return Err(err.into()),
367 }
368 Ok(())
369 }
370
371 fn unstage_hunk(&self, rel: &Path, hunk: &HunkId) -> Result<(), VcsError> {
372 let head_tree = self.head_tree()?;
373 let mut opts = git2::DiffOptions::new();
374 opts.context_lines(self.context_lines);
375 let diff = self
376 .repo
377 .diff_tree_to_index(head_tree.as_ref(), None, Some(&mut opts))?;
378 let patch = synthesize_patch(&diff, rel, hunk, true)?;
379 let diff = git2::Diff::from_buffer(&patch)?;
380 self.repo.apply(&diff, git2::ApplyLocation::Index, None)?;
381 Ok(())
382 }
383
384 fn discard(&self, rel: &Path) -> Result<(), VcsError> {
385 let head_tree = self.head_tree()?;
386 let mut opts = git2::DiffOptions::new();
387 opts.pathspec(rel).disable_pathspec_match(true);
388 let staged = self
389 .repo
390 .diff_tree_to_index(head_tree.as_ref(), None, Some(&mut opts))?;
391 if staged.deltas().len() > 0 {
392 return Err(VcsError::Rejected(
393 "file has staged changes; unstage first".into(),
394 ));
395 }
396 let status = self.repo.status_file(rel)?;
397 if status.contains(git2::Status::WT_NEW) {
398 fs::remove_file(self.workdir_path()?.join(rel))?;
399 return Ok(());
400 }
401 let mut checkout = git2::build::CheckoutBuilder::new();
408 checkout.path(rel).force().update_index(true);
409 self.repo.checkout_head(Some(&mut checkout))?;
410 Ok(())
411 }
412
413 fn commit(&self, message: &str) -> Result<String, VcsError> {
414 if message.trim().is_empty() {
415 return Err(VcsError::Rejected("empty commit message".into()));
416 }
417 let mut index = self.repo.index()?;
418 let tree_id = index.write_tree()?;
419 let tree = self.repo.find_tree(tree_id)?;
420 let signature = self.repo.signature()?;
421 let parent = match self.repo.head() {
422 Ok(head) => Some(head.peel_to_commit()?),
423 Err(err) if err.code() == git2::ErrorCode::UnbornBranch => None,
424 Err(err) => return Err(err.into()),
425 };
426 let parents: Vec<&git2::Commit<'_>> = parent.iter().collect();
427 let oid = self.repo.commit(
428 Some("HEAD"),
429 &signature,
430 &signature,
431 message,
432 &tree,
433 &parents,
434 )?;
435 Ok(oid.to_string())
436 }
437
438 fn head_message(&self) -> Result<String, VcsError> {
439 let commit = self.repo.head()?.peel_to_commit()?;
440 Ok(commit.message().unwrap_or_default().to_owned())
441 }
442
443 fn amend(&self, message: Option<&str>, use_index: bool) -> Result<String, VcsError> {
444 if let Some(message) = message
445 && message.trim().is_empty()
446 {
447 return Err(VcsError::Rejected("empty commit message".into()));
448 }
449 let head = self.repo.head()?.peel_to_commit()?;
450 let tree = if use_index {
453 let mut index = self.repo.index()?;
454 let tree_id = index.write_tree()?;
455 self.repo.find_tree(tree_id)?
456 } else {
457 head.tree()?
458 };
459 let oid = head.amend(Some("HEAD"), None, None, None, message, Some(&tree))?;
460 Ok(oid.to_string())
461 }
462
463 fn create_branch(&self, name: &str, checkout: bool) -> Result<(), VcsError> {
464 let head = self.repo.head()?.peel_to_commit()?;
465 self.repo.branch(name, &head, false)?;
466 if checkout {
467 self.checkout(name)?;
468 }
469 Ok(())
470 }
471
472 fn delete_branch(&self, name: &str) -> Result<(), VcsError> {
473 let mut branch = self.repo.find_branch(name, git2::BranchType::Local)?;
474 if branch.is_head() {
475 return Err(VcsError::Rejected(
476 "cannot delete the checked-out branch".into(),
477 ));
478 }
479 branch.delete()?;
480 Ok(())
481 }
482
483 fn checkout(&self, name: &str) -> Result<(), VcsError> {
484 let branch = self.repo.find_branch(name, git2::BranchType::Local)?;
485 let target = branch.get().peel(git2::ObjectType::Commit)?;
486 self.repo.checkout_tree(&target, None)?;
488 self.repo.set_head(&format!("refs/heads/{name}"))?;
489 Ok(())
490 }
491
492 fn stash_push(&self, message: Option<&str>) -> Result<(), VcsError> {
493 if !self.has_tracked_changes()? {
494 return Err(VcsError::Rejected("nothing to stash".into()));
495 }
496 let mut repo = git2::Repository::open(self.workdir_path()?)?;
499 let signature = repo.signature()?;
500 repo.stash_save2(&signature, message, None)?;
501 Ok(())
502 }
503
504 fn stash_pop(&self) -> Result<(), VcsError> {
505 let mut repo = git2::Repository::open(self.workdir_path()?)?;
506 match repo.stash_pop(0, None) {
507 Ok(()) => Ok(()),
508 Err(err) if err.code() == git2::ErrorCode::NotFound => {
509 Err(VcsError::Rejected("no stash to pop".into()))
510 }
511 Err(err) if err.code() == git2::ErrorCode::Conflict => Err(VcsError::Rejected(
514 "stash applied with conflicts; resolve them — the stash was kept".into(),
515 )),
516 Err(err) => Err(err.into()),
517 }
518 }
519
520 fn network_argv(&self, op: NetworkOp) -> Vec<String> {
521 let args: &[&str] = match op {
524 NetworkOp::Fetch => &["fetch"],
525 NetworkOp::FetchAll => &["fetch", "--all"],
526 };
527 std::iter::once("git")
528 .chain(args.iter().copied())
529 .map(str::to_owned)
530 .collect()
531 }
532
533 fn workdir(&self) -> Result<PathBuf, VcsError> {
534 Ok(self.workdir_path()?.to_path_buf())
535 }
536
537 fn remote_url(&self, name: &str) -> Result<Option<String>, VcsError> {
538 match self.repo.find_remote(name) {
539 Ok(remote) => Ok(remote.url().ok().map(str::to_owned)),
541 Err(err) if err.code() == git2::ErrorCode::NotFound => Ok(None),
542 Err(err) => Err(err.into()),
543 }
544 }
545
546 fn remotes(&self) -> Result<Vec<String>, VcsError> {
547 let array = self.repo.remotes()?;
548 let mut names = Vec::new();
549 for i in 0..array.len() {
550 if let Ok(Some(name)) = array.get(i) {
551 names.push(name.to_owned());
552 }
553 }
554 Ok(names)
555 }
556}
557
558fn synthesize_patch(
563 diff: &git2::Diff<'_>,
564 rel: &Path,
565 target: &HunkId,
566 reverse: bool,
567) -> Result<Vec<u8>, VcsError> {
568 let rel = rel.to_string_lossy();
569 for idx in 0..diff.deltas().len() {
570 let Some(patch) = git2::Patch::from_diff(diff, idx)? else {
571 continue;
572 };
573 let delta = patch.delta();
574 if delta.flags().is_binary() || delta_new_path(&delta) != rel {
575 continue;
576 }
577 for h in 0..patch.num_hunks() {
578 if hunk_id(&rel, &hunk_model_lines(&patch, h)?) == *target {
579 return render_hunk_patch(&patch, h, &rel, delta.status(), reverse);
580 }
581 }
582 }
583 Err(VcsError::Rejected("hunk not found (diff changed?)".into()))
584}
585
586fn render_hunk_patch(
587 patch: &git2::Patch<'_>,
588 h: usize,
589 rel: &str,
590 status: git2::Delta,
591 reverse: bool,
592) -> Result<Vec<u8>, VcsError> {
593 let added = matches!(status, git2::Delta::Added | git2::Delta::Untracked);
594 let deleted = status == git2::Delta::Deleted;
595 let mut out = Vec::new();
596 out.extend_from_slice(format!("diff --git a/{rel} b/{rel}\n").as_bytes());
597 if (added && !reverse) || (deleted && reverse) {
601 out.extend_from_slice(
602 format!("new file mode 100644\n--- /dev/null\n+++ b/{rel}\n").as_bytes(),
603 );
604 } else if (deleted && !reverse) || (added && reverse) {
605 out.extend_from_slice(
606 format!("deleted file mode 100644\n--- a/{rel}\n+++ /dev/null\n").as_bytes(),
607 );
608 } else {
609 out.extend_from_slice(format!("--- a/{rel}\n+++ b/{rel}\n").as_bytes());
610 }
611 let (hunk, line_count) = patch.hunk(h)?;
612 let (old, new) = (
613 (hunk.old_start(), hunk.old_lines()),
614 (hunk.new_start(), hunk.new_lines()),
615 );
616 let ((minus_start, minus_lines), (plus_start, plus_lines)) =
617 if reverse { (new, old) } else { (old, new) };
618 out.extend_from_slice(
619 format!("@@ -{minus_start},{minus_lines} +{plus_start},{plus_lines} @@\n").as_bytes(),
620 );
621 for l in 0..line_count {
622 let line = patch.line_in_hunk(h, l)?;
623 let origin = match (line.origin(), reverse) {
624 (' ', _) => Some(b' '),
625 ('+', false) | ('-', true) => Some(b'+'),
626 ('-', false) | ('+', true) => Some(b'-'),
627 ('=' | '>' | '<', _) => None,
631 _ => continue,
632 };
633 if let Some(origin) = origin {
634 out.push(origin);
635 }
636 out.extend_from_slice(line.content());
637 }
638 Ok(out)
639}
640
641fn workdir_diff_options(context_lines: u32) -> git2::DiffOptions {
642 let mut opts = git2::DiffOptions::new();
643 opts.include_untracked(true)
644 .recurse_untracked_dirs(true)
645 .show_untracked_content(true)
646 .context_lines(context_lines);
647 opts
648}
649
650fn short7(oid: &str) -> String {
651 oid.get(..7).unwrap_or(oid).to_owned()
652}
653
654fn diff_to_model(
655 repo: &git2::Repository,
656 diff: &mut git2::Diff<'_>,
657) -> Result<DiffModel, VcsError> {
658 let mut files = Vec::new();
659 for idx in 0..diff.deltas().len() {
660 if let Some(file) = build_file(repo, diff, idx)? {
661 files.push(file);
662 }
663 }
664 Ok(DiffModel { files })
668}
669
670fn build_file(
671 repo: &git2::Repository,
672 diff: &mut git2::Diff<'_>,
673 idx: usize,
674) -> Result<Option<FileDiff>, VcsError> {
675 let Some(patch) = git2::Patch::from_diff(diff, idx)? else {
676 return Ok(build_binary_file(diff, idx));
678 };
679 let delta = patch.delta();
680 if delta.flags().is_binary() {
681 return Ok(build_binary_file(diff, idx));
682 }
683 let file_path = delta_new_path(&delta);
684 let status = map_status(delta.status());
685 let old_path = if status == FileStatus::Renamed {
686 delta
687 .old_file()
688 .path()
689 .map(|p| p.to_string_lossy().into_owned())
690 } else {
691 None
692 };
693
694 let old_text = blob_text(repo, delta.old_file().id());
695 let new_text = new_side_text(repo, &delta, &file_path);
696
697 let hunks = patch_hunks(&patch, &file_path)?;
698
699 Ok(Some(FileDiff {
700 path: file_path,
701 old_path,
702 status,
703 binary: false,
704 old_text,
705 new_text,
706 hunks,
707 hashes: crate::model::HashCache::default(),
708 }))
709}
710
711pub fn rehunk_file(file: &FileDiff, context: u32) -> Option<Vec<Hunk>> {
716 if file.binary {
717 return None;
718 }
719 let (old, new) = (file.old_text.as_deref()?, file.new_text.as_deref()?);
720 let as_path = Path::new(&file.path);
721 let cap = u32::try_from(old.lines().count().max(new.lines().count())).unwrap_or(u32::MAX);
725 let mut opts = git2::DiffOptions::new();
726 opts.context_lines(context.min(cap));
727 let patch = git2::Patch::from_buffers(
728 old.as_bytes(),
729 Some(as_path),
730 new.as_bytes(),
731 Some(as_path),
732 Some(&mut opts),
733 )
734 .ok()?;
735 patch_hunks(&patch, &file.path).ok()
736}
737
738fn patch_hunks(patch: &git2::Patch<'_>, file_path: &str) -> Result<Vec<Hunk>, VcsError> {
741 let mut hunks = Vec::with_capacity(patch.num_hunks());
742 for h in 0..patch.num_hunks() {
743 let (hunk, _) = patch.hunk(h)?;
744 let lines = hunk_model_lines(patch, h)?;
745 let id = hunk_id(file_path, &lines);
746 hunks.push(Hunk {
747 id,
748 old_start: hunk.old_start(),
749 old_lines: hunk.old_lines(),
750 new_start: hunk.new_start(),
751 new_lines: hunk.new_lines(),
752 context: hunk_context(&hunk),
753 lines,
754 });
755 }
756 Ok(hunks)
757}
758
759fn hunk_context(hunk: &git2::DiffHunk<'_>) -> String {
763 let header = String::from_utf8_lossy(hunk.header());
764 match header.split_once(" @@") {
765 Some((_, rest)) => rest.trim_matches(['\n', '\r', ' ']).to_owned(),
766 None => String::new(),
767 }
768}
769
770fn hunk_model_lines(patch: &git2::Patch<'_>, h: usize) -> Result<Vec<DiffLine>, VcsError> {
774 let (_, line_count) = patch.hunk(h)?;
775 let mut lines = Vec::with_capacity(line_count);
776 for l in 0..line_count {
777 let line = patch.line_in_hunk(h, l)?;
778 let kind = match line.origin() {
779 '-' => LineKind::Deleted,
780 '+' => LineKind::Added,
781 ' ' => LineKind::Context,
782 _ => continue,
784 };
785 let text = String::from_utf8_lossy(line.content())
786 .trim_end_matches(['\n', '\r'])
787 .to_owned();
788 lines.push(DiffLine::new(
789 kind,
790 line.old_lineno(),
791 line.new_lineno(),
792 text,
793 ));
794 }
795 Ok(lines)
796}
797
798fn build_binary_file(diff: &git2::Diff<'_>, idx: usize) -> Option<FileDiff> {
799 let delta = diff.get_delta(idx)?;
800 Some(FileDiff {
801 path: delta_new_path(&delta),
802 old_path: None,
803 status: map_status(delta.status()),
804 binary: true,
805 old_text: None,
806 new_text: None,
807 hunks: Vec::new(),
808 hashes: crate::model::HashCache::default(),
809 })
810}
811
812fn delta_new_path(delta: &git2::DiffDelta<'_>) -> String {
813 delta
814 .new_file()
815 .path()
816 .or_else(|| delta.old_file().path())
817 .map(|p| p.to_string_lossy().into_owned())
818 .unwrap_or_default()
819}
820
821fn map_status(status: git2::Delta) -> FileStatus {
822 match status {
823 git2::Delta::Added => FileStatus::Added,
824 git2::Delta::Deleted => FileStatus::Deleted,
825 git2::Delta::Renamed => FileStatus::Renamed,
826 git2::Delta::Untracked => FileStatus::Untracked,
827 _ => FileStatus::Modified,
828 }
829}
830
831fn blob_text(repo: &git2::Repository, oid: git2::Oid) -> Option<String> {
832 if oid.is_zero() {
833 return None;
834 }
835 let blob = repo.find_blob(oid).ok()?;
836 if blob.is_binary() {
837 return None;
838 }
839 String::from_utf8(blob.content().to_vec()).ok()
840}
841
842fn new_side_text(
845 repo: &git2::Repository,
846 delta: &git2::DiffDelta<'_>,
847 rel: &str,
848) -> Option<String> {
849 if delta.status() == git2::Delta::Deleted {
850 return None;
851 }
852 if let Some(text) = blob_text(repo, delta.new_file().id()) {
853 return Some(text);
854 }
855 let root = repo.workdir()?;
856 fs::read_to_string(root.join(rel)).ok()
857}