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